{ "version": "https://jsonfeed.org/version/1.1", "title": "JavaGuide", "home_page_url": "https://javaguide.cn/", "feed_url": "https://javaguide.cn/feed.json", "description": "「Java学习指北 + Java面试指南」一份涵盖大部分 Java 程序员所需要掌握的核心知识。准备 Java 面试,复习 Java 知识点,首选 JavaGuide! ", "favicon": "https://javaguide.cn/favicon.ico", "items": [ { "title": "Redis为什么用跳表实现有序集合", "url": "https://javaguide.cn/database/redis/redis-skiplist.html", "id": "https://javaguide.cn/database/redis/redis-skiplist.html", "summary": "前言 近几年针对 Redis 面试时会涉及常见数据结构的底层设计,其中就有这么一道比较有意思的面试题:“Redis 的有序集合底层为什么要用跳表,而不用平衡树、红黑树或者 B+树?”。 本文就以这道大厂常问的面试题为切入点,带大家详细了解一下跳表这个数据结构。 本文整体脉络如下图所示,笔者会从有序集合的基本使用到跳表的源码分析和实现,让你会对 Redi...", "content_html": "
近几年针对 Redis 面试时会涉及常见数据结构的底层设计,其中就有这么一道比较有意思的面试题:“Redis 的有序集合底层为什么要用跳表,而不用平衡树、红黑树或者 B+树?”。
\n本文就以这道大厂常问的面试题为切入点,带大家详细了解一下跳表这个数据结构。
\n本文整体脉络如下图所示,笔者会从有序集合的基本使用到跳表的源码分析和实现,让你会对 Redis 的有序集合底层实现的跳表有着更深刻的理解和掌握。
\n\n这里我们需要先了解一下 Redis 用到跳表的数据结构有序集合的使用,Redis 有个比较常用的数据结构叫有序集合(sorted set,简称 zset),正如其名它是一个可以保证有序且元素唯一的集合,所以它经常用于排行榜等需要进行统计排列的场景。
\n这里我们通过命令行的形式演示一下排行榜的实现,可以看到笔者分输入 3 名用户:xiaoming、xiaohong、xiaowang,它们的score分别是 60、80、60,最终按照成绩升级降序排列。
\n\n127.0.0.1:6379> zadd rankList 60 xiaoming\n(integer) 1\n127.0.0.1:6379> zadd rankList 80 xiaohong\n(integer) 1\n127.0.0.1:6379> zadd rankList 60 xiaowang\n(integer) 1\n\n# 返回有序集中指定区间内的成员,通过索引,分数从高到低\n127.0.0.1:6379> ZREVRANGE rankList 0 100 WITHSCORES\n1) \"xiaohong\"\n2) \"80\"\n3) \"xiaowang\"\n4) \"60\"\n5) \"xiaoming\"\n6) \"60\"\n
此时我们通过 object
指令查看 zset 的数据结构,可以看到当前有序集合存储的还是是ziplist(压缩列表)。
127.0.0.1:6379> object encoding rankList\n\"ziplist\"\n
因为设计者考虑到 Redis 数据存放于内存,为了节约宝贵的内存空间在有序集合在元素小于 64 字节且个数小于 128 的时候,会使用 ziplist,而这个阈值的默认值的设置就来自下面这两个配置项。
\nzset-max-ziplist-value 64\nzset-max-ziplist-entries 128\n
一旦有序集合中的某个元素超出这两个其中的一个阈值它就会转为 skiplist(实际是 dict+skiplist,还会借用字典来提高获取指定元素的效率)。
\n我们不妨在添加一个大于 64 字节的元素,可以看到有序集合的底层存储转为 skiplist。
\n127.0.0.1:6379> zadd rankList 90 yigemingzihuichaoguo64zijiedeyonghumingchengyongyuceshitiaobiaodeshijiyunyong\n(integer) 1\n\n# 超过阈值,转为跳表\n127.0.0.1:6379> object encoding rankList\n\"skiplist\"\n
也就是说,ZSet 有两种不同的实现,分别是 ziplist 和 skiplist,具体使用哪种结构进行存储的规则如下:
\n为了更好的回答上述问题以及更好的理解和掌握跳表,这里可以通过手写一个简单的跳表的形式来帮助读者理解跳表这个数据结构。
\n我们都知道有序链表在添加、查询、删除的平均时间复杂都都是O(n)即线性增长,所以一旦节点数量达到一定体量后其性能表现就会非常差劲。而跳表我们完全可以理解为在原始链表基础上,建立多级索引,通过多级索引检索定位将增删改查的时间复杂度变为O(log n)。
\n可能这里说的有些抽象,我们举个例子,以下图跳表为例,其原始链表存储按序存储 1-10,有 2 级索引,每级索引的索引个数都是基于下层元素个数的一半。
\n\n假如我们需要查询元素 6,其工作流程如下:
\n相较于原始有序链表需要 6 次,我们的跳表通过建立多级索引,我们只需两次就直接定位到了目标元素,其查寻的复杂度被直接优化为O(log n)。
\n\n对应的添加也是一个道理,假如我们需要在这个有序集合中添加一个元素 7,那么我们就需要通过跳表找到小于元素 7 的最大值,也就是下图元素 6 的位置,将其插入到元素 6 的后面,让元素 6 的索引指向新插入的节点 7,其工作流程如下:
\n这里我们又面临一个问题,我们是否需要为元素 7 建立索引,索引多高合适?
\n我们上文提到,理想情况是每一层索引是下一层元素个数的二分之一,假设我们的总共有 16 个元素,对应各级索引元素个数应该是:
\n1. 一级索引:16/2=8\n2. 二级索引:8/2 =4\n3. 三级索引:4/2=2\n
由此我们用数学归纳法可知:
\n1. 一级索引:16/2=16/2^1=8\n2. 二级索引:8/2 => 16/2^2 =4\n3. 三级索引:4/2=>16/2^3=2\n
假设元素个数为 n,那么对应 k 层索引的元素个数 r 计算公式为:
\nr=n/2^k\n
同理我们再来推断以下索引的最大高度,一般来说最高级索引的元素个数为 2,我们设元素总个数为 n,索引高度为 h,代入上述公式可得:
\n2= n/2^h\n=> 2*2^h=n\n=> 2^(h+1)=n\n=> h+1=log2^n\n=> h=log2^n -1\n
而 Redis 又是内存数据库,我们假设元素最大个数是65536,我们把65536代入上述公式可知最大高度为 16。所以我们建议添加一个元素后为其建立的索引高度不超过 16。
\n因为我们要求尽可能保证每一个上级索引都是下级索引的一半,在实现高度生成算法时,我们可以这样设计:
\n我们回过头,上述插入 7 之后,我们通过随机算法得到 2,即要为其建立 1 级索引:
\n\n最后我们再来说说删除,假设我们这里要删除元素 10,我们必须定位到当前跳表各层元素小于 10 的最大值,索引执行步骤为:
\n有了整体的思路之后,我们可以开始实现一个跳表了,首先定义一下跳表中的节点Node,从上文的演示中可以看出每一个Node它都包含以下几个元素:
\n为了更方便统一管理Node后继节点地址和多级索引指向的元素地址,笔者在Node中设置了一个forwards数组,用于记录原始链表节点的后继节点和多级索引的后继节点指向。
\n以下图为例,我们forwards数组长度为 5,其中索引 0记录的是原始链表节点的后继节点地址,而其余自底向上表示从 1 级索引到 4 级索引的后继节点指向。
\n\n于是我们的就有了这样一个代码定义,可以看出笔者对于数组的长度设置为固定的 16**(上文的推算最大高度建议是 16),默认data为-1,节点最大高度maxLevel初始化为 1,注意这个maxLevel**的值代表原始链表加上索引的总高度。
\n/**\n * 跳表索引最大高度为16\n */\nprivate static final int MAX_LEVEL = 16;\n\nclass Node {\n private int data = -1;\n private Node[] forwards = new Node[MAX_LEVEL];\n private int maxLevel = 0;\n\n}\n
定义好节点之后,我们先实现以下元素的添加,添加元素时首先自然是设置data这一步我们直接根据将传入的value设置到data上即可。
\n然后就是高度maxLevel的设置 ,我们在上文也已经给出了思路,默认高度为 1,即只有一个原始链表节点,通过随机算法每次大于 0.5 索引高度加 1,由此我们得出高度计算的算法randomLevel()
:
/**\n * 理论来讲,一级索引中元素个数应该占原始数据的 50%,二级索引中元素个数占 25%,三级索引12.5% ,一直到最顶层。\n * 因为这里每一层的晋升概率是 50%。对于每一个新插入的节点,都需要调用 randomLevel 生成一个合理的层数。\n * 该 randomLevel 方法会随机生成 1~MAX_LEVEL 之间的数,且 :\n * 50%的概率返回 1\n * 25%的概率返回 2\n * 12.5%的概率返回 3 ...\n * @return\n */\nprivate int randomLevel() {\n int level = 1;\n while (Math.random() > PROB && level < MAX_LEVEL) {\n ++level;\n }\n return level;\n}\n
然后再设置当前要插入的Node和Node索引的后继节点地址,这一步稍微复杂一点,我们假设当前节点的高度为 4,即 1 个节点加 3 个索引,所以我们创建一个长度为 4 的数组maxOfMinArr ,遍历各级索引节点中小于当前value的最大值。
\n假设我们要插入的value为 5,我们的数组查找结果当前节点的前驱节点和 1 级索引、2 级索引的前驱节点都为 4,三级索引为空。
\n\n然后我们基于这个数组maxOfMinArr 定位到各级的后继节点,让插入的元素 5 指向这些后继节点,而maxOfMinArr指向 5,结果如下图:
\n\n转化成代码就是下面这个形式,是不是很简单呢?我们继续:
\n/**\n * 默认情况下的高度为1,即只有自己一个节点\n */\nprivate int leveCount = 1;\n\n/**\n * 跳表最底层的节点,即头节点\n */\nprivate Node h = new Node();\n\npublic void add(int value) {\n\n //随机生成高度\n int level = randomLevel();\n\n Node newNode = new Node();\n newNode.data = value;\n newNode.maxLevel = level;\n\n //创建一个node数组,用于记录小于当前value的最大值\n Node[] maxOfMinArr = new Node[level];\n //默认情况下指向头节点\n for (int i = 0; i < level; i++) {\n maxOfMinArr[i] = h;\n }\n\n //基于上述结果拿到当前节点的后继节点\n Node p = h;\n for (int i = level - 1; i >= 0; i--) {\n while (p.forwards[i] != null && p.forwards[i].data < value) {\n p = p.forwards[i];\n }\n maxOfMinArr[i] = p;\n }\n\n //更新前驱节点的后继节点为当前节点newNode\n for (int i = 0; i < level; i++) {\n newNode.forwards[i] = maxOfMinArr[i].forwards[i];\n maxOfMinArr[i].forwards[i] = newNode;\n }\n\n //如果当前newNode高度大于跳表最高高度则更新leveCount\n if (leveCount < level) {\n leveCount = level;\n }\n\n}\n
查询逻辑比较简单,从跳表最高级的索引开始定位找到小于要查的 value 的最大值,以下图为例,我们希望查找到节点 8:
\n所以我们的代码实现也很上述步骤差不多,从最高级索引开始向前查找,如果不为空且小于要查找的值,则继续向前搜寻,遇到不小于的节点则继续向下,如此往复,直到得到当前跳表中小于查找值的最大节点,查看其前驱是否等于要查找的值:
\npublic Node get(int value) {\n Node p = h;\n //找到小于value的最大值\n for (int i = leveCount - 1; i >= 0; i--) {\n while (p.forwards[i] != null && p.forwards[i].data < value) {\n p = p.forwards[i];\n }\n }\n //如果p的前驱节点等于value则直接返回\n if (p.forwards[0] != null && p.forwards[0].data == value) {\n return p.forwards[0];\n }\n\n return null;\n}\n
最后是删除逻辑,需要查找各层级小于要删除节点的最大值,假设我们要删除 10:
\n/**\n * 删除\n *\n * @param value\n */\npublic void delete(int value) {\n Node p = h;\n //找到各级节点小于value的最大值\n Node[] updateArr = new Node[leveCount];\n for (int i = leveCount - 1; i >= 0; i--) {\n while (p.forwards[i] != null && p.forwards[i].data < value) {\n p = p.forwards[i];\n }\n updateArr[i] = p;\n }\n //查看原始层节点前驱是否等于value,若等于则说明存在要删除的值\n if (p.forwards[0] != null && p.forwards[0].data == value) {\n //从最高级索引开始查看其前驱是否等于value,若等于则将当前节点指向value节点的后继节点\n for (int i = leveCount - 1; i >= 0; i--) {\n if (updateArr[i].forwards[i] != null && updateArr[i].forwards[i].data == value) {\n updateArr[i].forwards[i] = updateArr[i].forwards[i].forwards[i];\n }\n }\n }\n\n //从最高级开始查看是否有一级索引为空,若为空则层级减1\n while (leveCount > 1 && h.forwards[leveCount] == null) {\n leveCount--;\n }\n\n}\n
完整代码如下,读者可自行参阅:
\npublic class SkipList {\n\n /**\n * 跳表索引最大高度为16\n */\n private static final int MAX_LEVEL = 16;\n\n /**\n * 每个节点添加一层索引高度的概率为二分之一\n */\n private static final float PROB = 0.5 f;\n\n /**\n * 默认情况下的高度为1,即只有自己一个节点\n */\n private int leveCount = 1;\n\n /**\n * 跳表最底层的节点,即头节点\n */\n private Node h = new Node();\n\n public SkipList() {}\n\n public class Node {\n private int data = -1;\n /**\n *\n */\n private Node[] forwards = new Node[MAX_LEVEL];\n private int maxLevel = 0;\n\n @Override\n public String toString() {\n return \"Node{\" +\n \"data=\" + data +\n \", maxLevel=\" + maxLevel +\n '}';\n }\n }\n\n public void add(int value) {\n\n //随机生成高度\n int level = randomLevel();\n\n Node newNode = new Node();\n newNode.data = value;\n newNode.maxLevel = level;\n\n //创建一个node数组,用于记录小于当前value的最大值\n Node[] maxOfMinArr = new Node[level];\n //默认情况下指向头节点\n for (int i = 0; i < level; i++) {\n maxOfMinArr[i] = h;\n }\n\n //基于上述结果拿到当前节点的后继节点\n Node p = h;\n for (int i = level - 1; i >= 0; i--) {\n while (p.forwards[i] != null && p.forwards[i].data < value) {\n p = p.forwards[i];\n }\n maxOfMinArr[i] = p;\n }\n\n //更新前驱节点的后继节点为当前节点newNode\n for (int i = 0; i < level; i++) {\n newNode.forwards[i] = maxOfMinArr[i].forwards[i];\n maxOfMinArr[i].forwards[i] = newNode;\n }\n\n //如果当前newNode高度大于跳表最高高度则更新leveCount\n if (leveCount < level) {\n leveCount = level;\n }\n\n }\n\n /**\n * 理论来讲,一级索引中元素个数应该占原始数据的 50%,二级索引中元素个数占 25%,三级索引12.5% ,一直到最顶层。\n * 因为这里每一层的晋升概率是 50%。对于每一个新插入的节点,都需要调用 randomLevel 生成一个合理的层数。\n * 该 randomLevel 方法会随机生成 1~MAX_LEVEL 之间的数,且 :\n * 50%的概率返回 1\n * 25%的概率返回 2\n * 12.5%的概率返回 3 ...\n * @return\n */\n private int randomLevel() {\n int level = 1;\n while (Math.random() > PROB && level < MAX_LEVEL) {\n ++level;\n }\n return level;\n }\n\n public Node get(int value) {\n Node p = h;\n //找到小于value的最大值\n for (int i = leveCount - 1; i >= 0; i--) {\n while (p.forwards[i] != null && p.forwards[i].data < value) {\n p = p.forwards[i];\n }\n }\n //如果p的前驱节点等于value则直接返回\n if (p.forwards[0] != null && p.forwards[0].data == value) {\n return p.forwards[0];\n }\n\n return null;\n }\n\n /**\n * 删除\n *\n * @param value\n */\n public void delete(int value) {\n Node p = h;\n //找到各级节点小于value的最大值\n Node[] updateArr = new Node[leveCount];\n for (int i = leveCount - 1; i >= 0; i--) {\n while (p.forwards[i] != null && p.forwards[i].data < value) {\n p = p.forwards[i];\n }\n updateArr[i] = p;\n }\n //查看原始层节点前驱是否等于value,若等于则说明存在要删除的值\n if (p.forwards[0] != null && p.forwards[0].data == value) {\n //从最高级索引开始查看其前驱是否等于value,若等于则将当前节点指向value节点的后继节点\n for (int i = leveCount - 1; i >= 0; i--) {\n if (updateArr[i].forwards[i] != null && updateArr[i].forwards[i].data == value) {\n updateArr[i].forwards[i] = updateArr[i].forwards[i].forwards[i];\n }\n }\n }\n\n //从最高级开始查看是否有一级索引为空,若为空则层级减1\n while (leveCount > 1 && h.forwards[leveCount] == null) {\n leveCount--;\n }\n\n }\n\n public void printAll() {\n Node p = h;\n //基于最底层的非索引层进行遍历,只要后继节点不为空,则速速出当前节点,并移动到后继节点\n while (p.forwards[0] != null) {\n System.out.println(p.forwards[0]);\n p = p.forwards[0];\n }\n\n }\n\n}\n
对应测试代码和输出结果如下:
\npublic static void main(String[] args) {\n SkipList skipList = new SkipList();\n for (int i = 0; i < 24; i++) {\n skipList.add(i);\n }\n\n System.out.println(\"**********输出添加结果**********\");\n skipList.printAll();\n\n SkipList.Node node = skipList.get(22);\n System.out.println(\"**********查询结果:\" + node+\" **********\");\n\n skipList.delete(22);\n System.out.println(\"**********删除结果**********\");\n skipList.printAll();\n\n\n }\n
输出结果:
\n**********输出添加结果**********\nNode{data=0, maxLevel=2}\nNode{data=1, maxLevel=3}\nNode{data=2, maxLevel=1}\nNode{data=3, maxLevel=1}\nNode{data=4, maxLevel=2}\nNode{data=5, maxLevel=2}\nNode{data=6, maxLevel=2}\nNode{data=7, maxLevel=2}\nNode{data=8, maxLevel=4}\nNode{data=9, maxLevel=1}\nNode{data=10, maxLevel=1}\nNode{data=11, maxLevel=1}\nNode{data=12, maxLevel=1}\nNode{data=13, maxLevel=1}\nNode{data=14, maxLevel=1}\nNode{data=15, maxLevel=3}\nNode{data=16, maxLevel=4}\nNode{data=17, maxLevel=2}\nNode{data=18, maxLevel=1}\nNode{data=19, maxLevel=1}\nNode{data=20, maxLevel=1}\nNode{data=21, maxLevel=3}\nNode{data=22, maxLevel=1}\nNode{data=23, maxLevel=1}\n**********查询结果:Node{data=22, maxLevel=1} **********\n**********删除结果**********\nNode{data=0, maxLevel=2}\nNode{data=1, maxLevel=3}\nNode{data=2, maxLevel=1}\nNode{data=3, maxLevel=1}\nNode{data=4, maxLevel=2}\nNode{data=5, maxLevel=2}\nNode{data=6, maxLevel=2}\nNode{data=7, maxLevel=2}\nNode{data=8, maxLevel=4}\nNode{data=9, maxLevel=1}\nNode{data=10, maxLevel=1}\nNode{data=11, maxLevel=1}\nNode{data=12, maxLevel=1}\nNode{data=13, maxLevel=1}\nNode{data=14, maxLevel=1}\nNode{data=15, maxLevel=3}\nNode{data=16, maxLevel=4}\nNode{data=17, maxLevel=2}\nNode{data=18, maxLevel=1}\nNode{data=19, maxLevel=1}\nNode{data=20, maxLevel=1}\nNode{data=21, maxLevel=3}\nNode{data=23, maxLevel=1}\n
最后,我们再来回答一下文章开头的那道面试题: “Redis 的有序集合底层为什么要用跳表,而不用平衡树、红黑树或者 B+树?”。
\n先来说说它和平衡树的比较,平衡树我们又会称之为 AVL 树,是一个严格的平衡二叉树,平衡条件必须满足(所有节点的左右子树高度差不超过 1,即平衡因子为范围为 [-1,1]
)。平衡树的插入、删除和查询的时间复杂度和跳表一样都是 O(log n)。
对于范围查询来说,它也可以通过中序遍历的方式达到和跳表一样的效果。但是它的每一次插入或者删除操作都需要保证整颗树左右节点的绝对平衡,只要不平衡就要通过旋转操作来保持平衡,这个过程是比较耗时的。
\n\n跳表诞生的初衷就是为了克服平衡树的一些缺点,跳表的发明者在论文《Skip lists: a probabilistic alternative to balanced trees》中有详细提到:
\n\n\n\nSkip lists are a data structure that can be used in place of balanced trees. Skip lists use probabilistic balancing rather than strictly enforced balancing and as a result the algorithms for insertion and deletion in skip lists are much simpler and significantly faster than equivalent algorithms for balanced trees.
\n跳表是一种可以用来代替平衡树的数据结构。跳表使用概率平衡而不是严格强制的平衡,因此,跳表中的插入和删除算法比平衡树的等效算法简单得多,速度也快得多。
\n
笔者这里也贴出了 AVL 树插入操作的核心代码,可以看出每一次添加操作都需要进行一次递归定位插入位置,然后还需要根据回溯到根节点检查沿途的各层节点是否失衡,再通过旋转节点的方式进行调整。
\n// 向二分搜索树中添加新的元素(key, value)\npublic void add(K key, V value) {\n root = add(root, key, value);\n}\n\n// 向以node为根的二分搜索树中插入元素(key, value),递归算法\n// 返回插入新节点后二分搜索树的根\nprivate Node add(Node node, K key, V value) {\n\n if (node == null) {\n size++;\n return new Node(key, value);\n }\n\n if (key.compareTo(node.key) < 0)\n node.left = add(node.left, key, value);\n else if (key.compareTo(node.key) > 0)\n node.right = add(node.right, key, value);\n else // key.compareTo(node.key) == 0\n node.value = value;\n\n node.height = 1 + Math.max(getHeight(node.left), getHeight(node.right));\n\n int balanceFactor = getBalanceFactor(node);\n\n // LL型需要右旋\n if (balanceFactor > 1 && getBalanceFactor(node.left) >= 0) {\n return rightRotate(node);\n }\n\n //RR型失衡需要左旋\n if (balanceFactor < -1 && getBalanceFactor(node.right) <= 0) {\n return leftRotate(node);\n }\n\n //LR需要先左旋成LL型,然后再右旋\n if (balanceFactor > 1 && getBalanceFactor(node.left) < 0) {\n node.left = leftRotate(node.left);\n return rightRotate(node);\n }\n\n //RL\n if (balanceFactor < -1 && getBalanceFactor(node.right) > 0) {\n node.right = rightRotate(node.right);\n return leftRotate(node);\n }\n return node;\n}\n
红黑树(Red Black Tree)也是一种自平衡二叉查找树,它的查询性能略微逊色于 AVL 树,但插入和删除效率更高。红黑树的插入、删除和查询的时间复杂度和跳表一样都是 O(log n)。
\n红黑树是一个黑平衡树,即从任意节点到另外一个叶子叶子节点,它所经过的黑节点是一样的。当对它进行插入操作时,需要通过旋转和染色(红黑变换)来保证黑平衡。不过,相较于 AVL 树为了维持平衡的开销要小一些。关于红黑树的详细介绍,可以查看这篇文章:红黑树。
\n相比较于红黑树来说,跳表的实现也更简单一些。并且,按照区间来查找数据这个操作,红黑树的效率没有跳表高。
\n\n对应红黑树添加的核心代码如下,读者可自行参阅理解:
\nprivate Node < K, V > add(Node < K, V > node, K key, V val) {\n\n if (node == null) {\n size++;\n return new Node(key, val);\n\n }\n\n if (key.compareTo(node.key) < 0) {\n node.left = add(node.left, key, val);\n } else if (key.compareTo(node.key) > 0) {\n node.right = add(node.right, key, val);\n } else {\n node.val = val;\n }\n\n //左节点不为红,右节点为红,左旋\n if (isRed(node.right) && !isRed(node.left)) {\n node = leftRotate(node);\n }\n\n //左链右旋\n if (isRed(node.left) && isRed(node.left.left)) {\n node = rightRotate(node);\n }\n\n //颜色翻转\n if (isRed(node.left) && isRed(node.right)) {\n flipColors(node);\n }\n\n return node;\n}\n
想必使用 MySQL 的读者都知道 B+树这个数据结构,B+树是一种常用的数据结构,具有以下特点:
\n所以,B+树更适合作为数据库和文件系统中常用的索引结构之一,它的核心思想是通过可能少的 IO 定位到尽可能多的索引来获得查询数据。对于 Redis 这种内存数据库来说,它对这些并不感冒,因为 Redis 作为内存数据库它不可能存储大量的数据,所以对于索引不需要通过 B+树这种方式进行维护,只需按照概率进行随机维护即可,节约内存。而且使用跳表实现 zset 时相较前者来说更简单一些,在进行插入时只需通过索引将数据插入到链表中合适的位置再随机维护一定高度的索引即可,也不需要像 B+树那样插入时发现失衡时还需要对节点分裂与合并。
\n当然我们也可以通过 Redis 的作者自己给出的理由:
\n\n\nThere are a few reasons:
\n
\n1、They are not very memory intensive. It's up to you basically. Changing parameters about the probability of a node to have a given number of levels will make then less memory intensive than btrees.
\n2、A sorted set is often target of many ZRANGE or ZREVRANGE operations, that is, traversing the skip list as a linked list. With this operation the cache locality of skip lists is at least as good as with other kind of balanced trees.
\n3、They are simpler to implement, debug, and so forth. For instance thanks to the skip list simplicity I received a patch (already in Redis master) with augmented skip lists implementing ZRANK in O(log(N)). It required little changes to the code.
翻译过来的意思就是:
\n\n\n有几个原因:
\n1、它们不是很占用内存。这主要取决于你。改变节点拥有给定层数的概率的参数,会使它们比 B 树更节省内存。
\n2、有序集合经常是许多 ZRANGE 或 ZREVRANGE 操作的目标,也就是说,以链表的方式遍历跳表。通过这种操作,跳表的缓存局部性至少和其他类型的平衡树一样好。
\n3、它们更容易实现、调试等等。例如,由于跳表的简单性,我收到了一个补丁(已经在 Redis 主分支中),用增强的跳表实现了 O(log(N))的 ZRANK。它只需要对代码做很少的修改。
\n
本文通过大量篇幅介绍跳表的工作原理和实现,帮助读者更进一步的熟悉跳表这一数据结构的优劣,最后再结合各个数据结构操作的特点进行比对,从而帮助读者更好的理解这道面试题,建议读者实现理解跳表时,尽可能配合执笔模拟来了解跳表的增删改查详细过程。
\n开发岗中总是会考很多计算机网络的知识点,但如果让面试官只靠一道题,便涵盖最多的计网知识点,那可能就是 网页浏览的全过程 了。本篇文章将带大家从头到尾过一遍这道被考烂的面试题,必会!!!
\n总的来说,网络通信模型可以用下图来表示,也就是大家只要熟记网络结构五层模型,按照这个体系,很多知识点都能顺出来了。访问网页的过程也是如此。
\n\n开始之前,我们先简单过一遍完整流程:
\n一切的开始——打开浏览器,在地址栏输入 URL,回车确认。那么,什么是 URL?访问 URL 有什么用?
\nURL(Uniform Resource Locators),即统一资源定位器。网络上的所有资源都靠 URL 来定位,每一个文件就对应着一个 URL,就像是路径地址。理论上,文件资源和 URL 一一对应。实际上也有例外,比如某些 URL 指向的文件已经被重定位到另一个位置,这样就有多个 URL 指向同一个文件。
\nftp:
。/
开始,表示从服务器上根目录开始进行索引到的文件路径,上图中要访问的文件就是服务器根目录下/path/to/myfile.html
。早先的设计是该文件通常物理存储于服务器主机上,但现在随着网络技术的进步,该文件不一定会物理存储在服务器主机上,有可能存放在云上,而文件路径也有可能是虚拟的(遵循某种规则)。key=value
,每一个键值对使用&
隔开。参数的具体含义和请求操作的具体方法有关。#
开头,并且不会作为请求的一部分发送给服务端。键入了 URL 之后,第一个重头戏登场——DNS 服务器解析。DNS(Domain Name System)域名系统,要解决的是 域名和 IP 地址的映射问题 。毕竟,域名只是一个网址便于记住的名字,而网址真正存在的地址其实是 IP 地址。
\n传送门:DNS 域名系统详解(应用层)
\n利用 DNS 拿到了目标主机的 IP 地址之后,浏览器便可以向目标 IP 地址发送 HTTP 报文,请求需要的资源了。在这里,根据目标网站的不同,请求报文可能是 HTTP 协议或安全性增强的 HTTPS 协议。
\n传送门:
\n\n由于 HTTP 协议是基于 TCP 协议的,在应用层的数据封装好以后,要交给传输层,经 TCP 协议继续封装。
\nTCP 协议保证了数据传输的可靠性,是数据包传输的主力协议。
\n传送门:
\n\n终于,来到网络层,此时我们的主机不再是和另一台主机进行交互了,而是在和中间系统进行交互。也就是说,应用层和传输层都是端到端的协议,而网络层及以下都是中间件的协议了。
\n网络层的的核心功能——转发与路由,必会!!!如果面试官问到了网络层,而你恰好又什么都不会的话,最最起码要说出这五个字——转发与路由。
\n所以到目前为止,我们的数据包经过了应用层、传输层的封装,来到了网络层,终于开始准备在物理层面传输了,第一个要解决的问题就是——**往哪里传输?或者说,要把数据包发到哪个路由器上?**这便是 BGP 协议要解决的问题。
\n", "image": "https://oss.javaguide.cn/github/javaguide/cs-basics/network/five-layers.png", "date_published": "2024-01-29T14:06:19.000Z", "date_modified": "2024-01-31T11:49:55.000Z", "authors": [], "tags": [ "计算机基础" ] }, { "title": "数据冷热分离详解", "url": "https://javaguide.cn/high-performance/data-cold-hot-separation.html", "id": "https://javaguide.cn/high-performance/data-cold-hot-separation.html", "summary": "什么是数据冷热分离? 数据冷热分离是指根据数据的访问频率和业务重要性,将数据分为冷数据和热数据,冷数据一般存储在存储在低成本、低性能的介质中,热数据高性能存储介质中。 冷数据和热数据 热数据是指经常被访问和修改且需要快速访问的数据,冷数据是指不经常访问,对当前项目价值较低,但需要长期保存的数据。 冷热数据到底如何区分呢?有两个常见的区分方法: 时间维度...", "content_html": "数据冷热分离是指根据数据的访问频率和业务重要性,将数据分为冷数据和热数据,冷数据一般存储在存储在低成本、低性能的介质中,热数据高性能存储介质中。
\n热数据是指经常被访问和修改且需要快速访问的数据,冷数据是指不经常访问,对当前项目价值较低,但需要长期保存的数据。
\n冷热数据到底如何区分呢?有两个常见的区分方法:
\n几年前的数据并不一定都是热数据,例如一些优质文章发表几年后依然有很多人访问,大部分普通用户新发表的文章却基本没什么人访问。
\n这两种区分冷热数据的方法各有优劣,实际项目中,可以将两者结合使用。
\n冷热分离的思想非常简单,就是对数据进行分类,然后分开存储。冷热分离的思想可以应用到很多领域和场景中,而不仅仅是数据存储,例如:
\n冷数据迁移方案:
\n如果你的公司有 DBA 的话,也可以让 DBA 进行冷数据的人工迁移,一次迁移完成冷数据到冷库。然后,再搭配上面介绍的方案实现后续冷数据的迁移工作。
\n冷数据的存储要求主要是容量大,成本低,可靠性高,访问速度可以适当牺牲。
\n冷数据存储方案:
\n如果公司成本预算足的话,也可以直接上 TiDB 这种分布式关系型数据库,直接一步到位。TiDB 6.0 正式支持数据冷热存储分离,可以降低 SSD 使用成本。使用 TiDB 6.0 的数据放置功能,可以在同一个集群实现海量数据的冷热存储,将新的热数据存入 SSD,历史冷数据存入 HDD。
\n查询偏移量过大的场景我们称为深度分页,这会导致查询性能较低,例如:
\n# MySQL 在无法利用索引的情况下跳过1000000条记录后,再获取10条记录\nSELECT * FROM t_order ORDER BY id LIMIT 1000000, 10\n
这里以 MySQL 数据库为例介绍一下如何优化深度分页。
\n当可以保证 ID 的连续性时,根据 ID 范围进行分页是比较好的解决方案:
\n# 查询指定 ID 范围的数据\nSELECT * FROM t_order WHERE id > 100000 AND id <= 100010 ORDER BY id\n# 也可以通过记录上次查询结果的最后一条记录的ID进行下一页的查询:\nSELECT * FROM t_order WHERE id > 100000 LIMIT 10\n
这种优化方式限制比较大,且一般项目的 ID 也没办法保证完全连续。
\n我们先查询出 limit 第一个参数对应的主键值,再根据这个主键值再去过滤并 limit,这样效率会更快一些。
\n阿里巴巴《Java 开发手册》中也有对应的描述:
\n\n\n利用延迟关联或者子查询优化超多分页场景。
\n\n
# 通过子查询来获取 id 的起始值,把 limit 1000000 的条件转移到子查询\nSELECT * FROM t_order WHERE id >= (SELECT id FROM t_order limit 1000000, 1) LIMIT 10;\n
不过,子查询的结果会产生一张新表,会影响性能,应该尽量避免大量使用子查询。并且,这种方法只适用于 ID 是正序的。在复杂分页场景,往往需要通过过滤条件,筛选到符合条件的 ID,此时的 ID 是离散且不连续的。
\n当然,我们也可以利用子查询先去获取目标分页的 ID 集合,然后再根据 ID 集合获取内容,但这种写法非常繁琐,不如使用 INNER JOIN 延迟关联。
\n延迟关联的优化思路,跟子查询的优化思路其实是一样的:都是把条件转移到主键索引树,减少回表的次数。不同点是,延迟关联使用了 INNER JOIN(内连接) 包含子查询。
\nSELECT t1.* FROM t_order t1\nINNER JOIN (SELECT id FROM t_order limit 1000000, 10) t2\nON t1.id = t2.id\nLIMIT 10;\n
除了使用 INNER JOIN 之外,还可以使用逗号连接子查询。
\nSELECT t1.* FROM t_order t1,\n(SELECT id FROM t_order limit 1000000, 10) t2\nWHERE t1.id = t2.id;\n
索引中已经包含了所有需要获取的字段的查询方式称为覆盖索引。
\n覆盖索引的好处:
\n# 如果只需要查询 id, code, type 这三列,可建立 code 和 type 的覆盖索引\nSELECT id, code, type FROM t_order\nORDER BY code\nLIMIT 1000000, 10;\n
不过,当查询的结果集占表的总行数的很大一部分时,可能就不会走索引了,自动转换为全表扫描。当然了,也可以通过 FORCE INDEX
来强制查询优化器走索引,但这种提升效果一般不明显。
\n\n推荐语:作者用了很多生动的例子和故事展示了自己在美团的成长和感悟,看了之后受益颇多!
\n内容概览:
\n本文的作者提出了以下十条建议,希望能对其他职场人有所启发和帮助:
\n\n
\n\n- 结构化思考与表达,提高个人影响力
\n- 忘掉职级,该怼就怼,推动事情往前走
\n- 用好平台资源,结识优秀的人,学习通识课
\n- 一切都是争取来的,不要等待机会,要主动寻求
\n- 关注商业,升维到老板思维,看清趋势,及时止损
\n- 培养数据思维,利用数据了解世界,指导决策
\n- 做一个好\"销售\",无论是自己还是产品,都要学会展示和说服
\n- 少加班多运动,保持身心健康,提高工作效率
\n- 有随时可以离开的底气,不要被职场所困,借假修真,提升自己
\n- 只是一份工作,不要过分纠结,相信自己,走出去看看
\n
在美团的三年多时光,如同一部悠长的交响曲,高高低低,而今离开已有一段时间。闲暇之余,梳理了三年多的收获与感慨,总结成 10 条,既是对过去一段时光的的一个深情回眸,也是对未来之路的一份期许。
\n倘若一些感悟能为刚步入职场的年轻人,或是刚在职业生涯中崭露头角的后起之秀,带来一点点启示与帮助,也是莫大的荣幸。
\n美团是一家特别讲究方法论的公司,人人都要熟读四大名著《高效能人士的七个习惯》、《金字塔原理》、《用图表说话》和《学会提问》。
\n与结构化思考和表达相关的,是《金字塔原理》,作者是麦肯锡公司第一位女性咨询顾问。这本书告诉我们,思考和表达的过程,就像构建金字塔(或者构建一棵树),先有整体结论,再寻找证据,证据之间要讲究相互独立、而且能穷尽(MECE 原则),论证的过程也要按特定的顺序进行,比如时间顺序、空间顺序、重要性顺序……
\n作为大厂社畜,日常很大一部分工作就是写文档、看别人文档。大家做的事,但最后呈现的结果却有很大差异。一篇逻辑清晰、详略得当的文档,给人一种如沐春风的感受,能提炼出重要信息,是好的参考指南。
\n结构化思考与表达算是职场最通用的能力,也是打造个人影响力最重要的途径之一。
\n在阿里工作时,能看到每个人的 Title,看到江湖地位高(职级高+入职时间早)的同学,即便跟自己没有汇报关系,不自然的会多一层敬畏。推进工作时,会多一层压力,对方未读或已读未回时,不知如何应对。
\n美团只能看到每个人的坑位信息,还有 Ta 的上级。工作相关的问题,可以向任何人提问,如果协同方没有及时响应,隔段时间@一次,甚至\"怼一怼\",都没啥问题,事情一直往前推进才最重要。除了大象消息直接提问外,还有个大杀器--TT(公司级问题流转系统),在上面提问时,加上对方主管,如果对方未及时回应,问题会自动升级,每天定时 Push,直到解决为止。
\n我见到一些很年轻的同事,他们在推动 OKR、要资源的事上,很有一套,只要能达到自己的目标,不会考虑别人的感受,最终,他们还真能把事办成。
\n当然了,段位越高的人,越能用自己的人格魅力、影响力、资源等,去影响和推动事情的进程,而不是靠对他人的 Push。只是在拿结果的事上,不要把自己太当回事,把别人太当回事,大家在一起,也只是为了完成各自的任务,忘掉职级,该怼时还得怼。
\n没有人能在一家公司待一辈子,公司再牛,跟自己关系不大,重要的是,在有限的时间内,最大化用好平台资源。
\n在美团除了认识自己节点的同事外,有幸认识一群特别棒的协作方,还有其他 BU 的同学。
\n这些优秀的人身上,有很多共同的特质:谦虚、利他、乐于分享、双赢思维。
\n有两位做运营的同学。
\n一位是无意中关注他公众号结识上的。他公众号记录了很多职场成长、家庭建造上的思考和收获,还有定期个人复盘。他和太太都是大厂中层管理者,从文章中看到的不是他多厉害,而是非常接地气的故事。我们约饭了两次,有很多共同话题,现在还时不时有一些互动。
\n一位职级更高的同学,他在内网发起了一个\"请我喝一杯咖啡,和我一起聊聊个人困惑\"的活动,我报名参与了一期。和他聊天的过程,特别像是一场教练对话(最近学习教练课程时才感受到的),帮我排除干扰、聚焦目标的同时,也从他分享个人成长蜕变的过程,收获很多动力。(刚好自己最近也学习了教练技术,后面也准备采用类似的方式,去帮助曾经像我一样迷茫的人)
\n还有一些协作方同学。他们工作做得超级到位,能感受到,他们在乎他人时间;稍微有点出彩的事儿,不忘记拉上更多人。利他和双赢思维,在他们身上是最好的阐释。
\n除了结识优秀的人,向他们学习外,还可以关注各个通道/工种的课程资源。
\n在大厂,多数人的角色都是螺丝钉,但千万不要局限于做一颗螺丝钉。多去学习一些通识课,了解商业交付的各个环节,看清商业世界,明白自己的定位,超越自己的定位。
\n工作很多年了,很晚才明白这个道理。
\n之前一直认为,只要做好自己该做的,一定会被看见,被赏识,也会得到更多机会。但很多时候,这只是个人的一厢情愿。除了自己,不会有人关心你的权益。
\n社会主义初级阶段,我国国内的主要矛盾是人民日益增长的物质文化需要同落后的社会生产之间的矛盾。无论在哪里,资源都是稀缺的,自己在乎的,就得去争取。
\n想成长某个技能、想参与哪个模块、想做哪个项目,升职加薪……自己不提,不去争取,不会有人主动给你。
\n争不争取是一回事,能不能得到是一回事,只有争取,才有可能得到。争取了,即便没有得到,最终也没失去什么。
\n大公司,极度关注效率,大部分岗位,拆解的粒度越细,效率会越高,这些对组织是有利的。但对个人来说,则很容易螺丝钉化。
\n做技术的同学,更是这样。
\n做前端的同学,不会关注数据是如何落库的;做后端的同学,不会思考页面是否存在兼容性问题;做业务开发的,不用考虑微服务诸多中间件是如何搭建起来的……
\n大部分人都想着怎么把自己这摊子事搞好,不会去思考上下游同学在做些什么,更少有人真正关注商业,关心公司的盈利模式,关心每一次产品迭代到底带来哪些业务价值。
\n把手头的事做好是应该的,但绝不能停留在此。所有的产品,只有在商业社会产生交付,让客户真正获益,才是有价值的。
\n关注商业,能帮我们升维到老板思维,明白投入产出比,抓大放小;也帮助我们,在碰到不好的业务时,及时止损;更重要的是,它帮助我们真正看清趋势,提前做好准备。
\n《五分钟商学院》系列,是很好的商业入门级书籍。尽管作者刘润最近存在争议,但不可否认,他比我们大多数人段位还是高很多,他的书值得一读。
\n当今数字化时代,数据思维显得尤为重要。数据不仅可以帮助我们更好地了解世界,还可以指导我们的决策和行动。
\n非常幸运的是,在阿里和美团的两份经历,都是做商业化广告业务,在离钱💰最近的地方,也培养了数据的敏感性。见过商业数据指标的定义、加工、生产和应用全流程,也在不断熏陶下,能看懂大部分指标背后的价值。
\n除了直接面向业务的数据,还有研发协作全流程产生的数据。数据被记录和汇总统计后,能直观地看到每个环节的效率和质量。螺丝钉们的工作,也彻彻底底被数字量化,除了积极面对虚拟化、线上化、数字化外,我们别无他法。
\n受工作数据化的影响,生活中,我也渐渐变成了一个数据记录狂,日常运动(骑行、跑步、健走等)必须通过智能手表记录下来,没带 Apple Watch,感觉这次白运动了。每天也在很努力地完成三个圆环。
\n数据时代,我们沦为了透明人。也得益于数据被记录和分析,我们做任何事,都能快速得到反馈,这也是自我提升的一个重要环节。
\n就某种程度来说,所有的工作,本质都是销售。
\n这是很多大咖的观点,我也是很晚才明白这个道理。
\n我们去一家公司应聘,本质上是在讲一个「我很牛」的故事,销售的是自己;日常工作汇报、季度/年度述职、晋升答辩,是在销售自己;在任何一个场合曝光,也是在销售自己。
\n如果我们所服务的组织,对外提供的是一件产品或一项服务,所有上下游协作的同学,唯一在做的事就是,齐心协力把产品/服务卖出去, 我们本质做的还是销售。
\n所以, 千万不要看不起任何销售,也不要认为认为销售是一件很丢面子的事。
\n真正的大佬,随时随地都在销售。
\n在职场,大家都认同一个观点,工作是做不完的。
\n我们要做的是,用好时间管理四象限法,识别重要程度和优先级,有限时间,聚焦在固定几件事上。
\n这要求我们不断提高自己的问题识别能力、拆解能力,还有专注力。
\n我们会因为部分项目的需要而加班,但不会长期加班。
\n加班时间短一点,就能腾出更多时间运动。
\n最近一次线下培训课,认识一位老师 Hubert,Hubert 是一位超级有魅力的中年大叔(可以通过「有意思教练」的课程链接到他),从外企高管的位置离开后,和太太一起创办了一家培训机构。作为公司高层,日常工作非常忙,头发也有些花白了,但一身腱子肉胜过很多健身教练,给人的状态也是很年轻。聊天得知,Hubert 经常 5 点多起来泡健身房~
\n我身边还有一些同事,跟我年龄差不多,因为长期加班,发福严重,比实际年龄看起来苍老 10+岁;
\n还有同事曾经加班进 ICU,幸好后面身体慢慢恢复过来。
\n某某厂员工长期加班猝死的例子,更是屡见不鲜。
\n减少加班,增加运动,绝对是一件性价比极高的事。
\n当今职场,跟父辈时候完全不一样,职业的多样性和变化性越来越快,很少有人能够在同一份工作或同一个公司待一辈子。除了某些特定的岗位,如公务员、事业单位等,大多数人都会在职业生涯中经历多次的职业变化和调整。
\n在商业组织里,个体是弱势群体,但不要做弱者。每一段职场,每一项工作,都是上天给我们的修炼。
\n我很喜欢\"借假修真\"这个词。我们参与的大大小小的项目, 重要吗?对公司来说可能重要,对个人来说,则未必。我们去做,一方面是迫于生计;
\n另外一方面,参与每个项目的感悟、心得、体会,是真实存在的,很多的能力,都是在这个过程得到提升。
\n明白这一点,就不会被职场所困,会刻意在各样事上提升自己,积累的越多,对事务的本质理解的越深、越广,也越发相信很多底层知识是通用的,内心越平静,也会建立起随时都可以离开的底气。
\n工作中,我们时常会遇到各种挑战和困难,如发展瓶颈、难以处理的人和事,甚至职场 PUA 等。这些经历可能会让我们感到疲惫、沮丧,甚至怀疑自己的能力和价值。然而,重要的是要明白,困难只是成长道路上的暂时阻碍,而不是我们的定义。
\n写总结和复盘是很好的方式,可以帮我们理清思路,找到问题的根源,并学习如何应对类似的情况。但也要注意不要陷入自我怀疑和内耗的陷阱。遇到困难时,应该学会相信自己,积极寻找解决问题的方法,而不是过分纠结于自己的不足和错误。
\n内网常有同学匿名分享工作压力过大,常常失眠甚至中度抑郁,每次看到这些话题,非常难过。大环境不好,是不争的事实,但并不代表个体就没有出路。
\n我们容易预设困难,容易加很多\"可是\",当窗户布满灰尘时,不要试图努力把窗户擦干净,走出去吧,你将看到一片蔚蓝的天空。
\n写到最后,特别感恩美团三年多的经历。感谢我的 Leader 们,感谢曾经并肩作战过的小伙伴,感谢遇到的每一位和我一样在平凡的岗位,努力想带给身边一片微光的同学。所有的相遇,都是缘分。
\n", "date_published": "2023-12-17T07:37:37.000Z", "date_modified": "2023-12-30T09:14:13.000Z", "authors": [ { "name": "CityDreamer部落" } ], "tags": [ "技术文章精选集" ] }, { "title": "程序员如何快速学习新技术", "url": "https://javaguide.cn/high-quality-technical-articles/advanced-programmer/programmer-quickly-learn-new-technology.html", "id": "https://javaguide.cn/high-quality-technical-articles/advanced-programmer/programmer-quickly-learn-new-technology.html", "summary": " 推荐语:这是《Java 面试指北》练级攻略篇中的一篇文章,分享了我对于如何快速学习一门新技术的看法。 《Java 面试指北》练级攻略篇《Java 面试指北》练级攻略篇 很多时候,我们因为工作原因需要快速学习某项技术,进而在项目中应用。或者说,我们想要去面试的公司要求的某项技术我们之前没有接触过,为了应对面试需要,我们需要快速掌握这项技术。 作为一个人...", "content_html": "\n\n推荐语:这是《Java 面试指北》练级攻略篇中的一篇文章,分享了我对于如何快速学习一门新技术的看法。
\n\n
很多时候,我们因为工作原因需要快速学习某项技术,进而在项目中应用。或者说,我们想要去面试的公司要求的某项技术我们之前没有接触过,为了应对面试需要,我们需要快速掌握这项技术。
\n作为一个人纯自学出生的程序员,这篇文章简单聊聊自己对于如何快速学习某项技术的看法。
\n学习任何一门技术的时候,一定要先搞清楚这个技术是为了解决什么问题的。深入学习这个技术的之前,一定先从全局的角度来了解这个技术,思考一下它是由哪些模块构成的,提供了哪些功能,和同类的技术想必它有什么优势。
\n比如说我们在学习 Spring 的时候,通过 Spring 官方文档你就可以知道 Spring 最新的技术动态,Spring 包含哪些模块 以及 Spring 可以帮你解决什么问题。
\n\n再比如说我在学习消息队列的时候,我会先去了解这个消息队列一般在系统中有什么作用,帮助我们解决了什么问题。消息队列的种类很多,具体学习研究某个消息队列的时候,我会将其和自己已经学习过的消息队列作比较。像我自己在学习 RocketMQ 的时候,就会先将其和自己曾经学习过的第 1 个消息队列 ActiveMQ 进行比较,思考 RocketMQ 相对于 ActiveMQ 有了哪些提升,解决了 ActiveMQ 的哪些痛点,两者有哪些相似的地方,又有哪些不同的地方。
\n学习一个技术最有效最快的办法就是将这个技术和自己之前学到的技术建立连接,形成一个网络。
\n然后,我建议你先去看看官方文档的教程,运行一下相关的 Demo ,做一些小项目。
\n不过,官方文档通常是英文的,通常只有国产项目以及少部分国外的项目提供了中文文档。并且,官方文档介绍的往往也比较粗糙,不太适合初学者作为学习资料。
\n如果你看不太懂官网的文档,你也可以搜索相关的关键词找一些高质量的博客或者视频来看。 一定不要一上来就想着要搞懂这个技术的原理。
\n就比如说我们在学习 Spring 框架的时候,我建议你在搞懂 Spring 框架所解决的问题之后,不是直接去开始研究 Spring 框架的原理或者源码,而是先实际去体验一下 Spring 框架提供的核心功能 IoC(Inverse of Control:控制反转) 和 AOP(Aspect-Oriented Programming:面向切面编程),使用 Spring 框架写一些 Demo,甚至是使用 Spring 框架做一些小项目。
\n一言以蔽之, 在研究这个技术的原理之前,先要搞懂这个技术是怎么使用的。
\n这样的循序渐进的学习过程,可以逐渐帮你建立学习的快感,获得即时的成就感,避免直接研究原理性的知识而被劝退。
\n研究某个技术原理的时候,为了避免内容过于抽象,我们同样可以动手实践。
\n比如说我们学习 Tomcat 原理的时候,我们发现 Tomcat 的自定义线程池挺有意思,那我们自己也可以手写一个定制版的线程池。再比如我们学习 Dubbo 原理的时候,可以自己动手造一个简易版的 RPC 框架。
\n另外,学习项目中需要用到的技术和面试中需要用到的技术其实还是有一些差别的。
\n如果你学习某一项技术是为了在实际项目中使用的话,那你的侧重点就是学习这项技术的使用以及最佳实践,了解这项技术在使用过程中可能会遇到的问题。你的最终目标就是这项技术为项目带来了实际的效果,并且,这个效果是正面的。
\n如果你学习某一项技术仅仅是为了面试的话,那你的侧重点就应该放在这项技术在面试中最常见的一些问题上,也就是我们常说的八股文。
\n很多人一提到八股文,就是一脸不屑。在我看来,如果你不是死记硬背八股文,而是去所思考这些面试题的本质。那你在准备八股文的过程中,同样也能让你加深对这项技术的了解。
\n最后,最重要同时也是最难的还是 知行合一!知行合一!知行合一! 不论是编程还是其他领域,最重要不是你知道的有多少,而是要尽量做到知行合一。
\n", "image": "https://oss.javaguide.cn/javamianshizhibei/training-strategy-articles.png", "date_published": "2023-11-09T08:24:19.000Z", "date_modified": "2024-01-07T16:20:15.000Z", "authors": [], "tags": [ "技术文章精选集" ] }, { "title": "经典算法思想总结(含LeetCode题目推荐)", "url": "https://javaguide.cn/cs-basics/algorithms/classical-algorithm-problems-recommendations.html", "id": "https://javaguide.cn/cs-basics/algorithms/classical-algorithm-problems-recommendations.html", "summary": "贪心算法 算法思想 贪心的本质是选择每一阶段的局部最优,从而达到全局最优。 一般解题步骤 将问题分解为若干个子问题 找出适合的贪心策略 求解每一个子问题的最优解 将局部最优解堆叠成全局最优解 LeetCode 455.分发饼干:https://leetcode.cn/problems/assign-cookies/ 121.买卖股票的最佳时机:http...", "content_html": "贪心的本质是选择每一阶段的局部最优,从而达到全局最优。
\n455.分发饼干:https://leetcode.cn/problems/assign-cookies/
\n121.买卖股票的最佳时机:https://leetcode.cn/problems/best-time-to-buy-and-sell-stock/
\n122.买卖股票的最佳时机 II:https://leetcode.cn/problems/best-time-to-buy-and-sell-stock-ii/
\n55.跳跃游戏:https://leetcode.cn/problems/jump-game/
\n45.跳跃游戏 II:https://leetcode.cn/problems/jump-game-ii/
\n动态规划中每一个状态一定是由上一个状态推导出来的,这一点就区分于贪心,贪心没有状态推导,而是从局部直接选最优的。
\n经典题目:01 背包、完全背包
\n509.斐波那契数:https://leetcode.cn/problems/fibonacci-number/
\n746.使用最小花费爬楼梯:https://leetcode.cn/problems/min-cost-climbing-stairs/
\n416.分割等和子集:https://leetcode.cn/problems/partition-equal-subset-sum/
\n518.零钱兑换:https://leetcode.cn/problems/coin-change-ii/
\n647.回文子串:https://leetcode.cn/problems/palindromic-substrings/
\n516.最长回文子序列:https://leetcode.cn/problems/longest-palindromic-subsequence/
\n回溯算法实际上一个类似枚举的搜索尝试过程,主要是在搜索尝试过程中寻找问题的解,当发现已不满足求解条
\n件时,就“回溯”返回,尝试别的路径。其本质就是穷举。
\n经典题目:8 皇后
\n77.组合:https://leetcode.cn/problems/combinations/
\n39.组合总和:https://leetcode.cn/problems/combination-sum/
\n40.组合总和 II:https://leetcode.cn/problems/combination-sum-ii/
\n78.子集:https://leetcode.cn/problems/subsets/
\n90.子集 II:https://leetcode.cn/problems/subsets-ii/
\n51.N 皇后:https://leetcode.cn/problems/n-queens/
\n将一个规模为 N 的问题分解为 K 个规模较小的子问题,这些子问题相互独立且与原问题性质相同。求出子问题的解,就可得到原问题的解。
\n经典题目:二分查找、汉诺塔问题
\n108.将有序数组转换成二叉搜索数:https://leetcode.cn/problems/convert-sorted-array-to-binary-search-tree/
\n148.排序列表:https://leetcode.cn/problems/sort-list/
\n23.合并 k 个升序链表:https://leetcode.cn/problems/merge-k-sorted-lists/
\n", "date_published": "2023-11-07T08:25:02.000Z", "date_modified": "2023-12-30T09:14:13.000Z", "authors": [], "tags": [ "计算机基础" ] }, { "title": "常见数据结构经典LeetCode题目推荐", "url": "https://javaguide.cn/cs-basics/algorithms/common-data-structures-leetcode-recommendations.html", "id": "https://javaguide.cn/cs-basics/algorithms/common-data-structures-leetcode-recommendations.html", "summary": "数组 704.二分查找:https://leetcode.cn/problems/binary-search/ 80.删除有序数组中的重复项 II:https://leetcode.cn/problems/remove-duplicates-from-sorted-array-ii 977.有序数组的平方:https://leetcode.cn/pro...", "content_html": "704.二分查找:https://leetcode.cn/problems/binary-search/
\n80.删除有序数组中的重复项 II:https://leetcode.cn/problems/remove-duplicates-from-sorted-array-ii
\n977.有序数组的平方:https://leetcode.cn/problems/squares-of-a-sorted-array/
\n707.设计链表:https://leetcode.cn/problems/design-linked-list/
\n206.反转链表:https://leetcode.cn/problems/reverse-linked-list/
\n92.反转链表 II:https://leetcode.cn/problems/reverse-linked-list-ii/
\n61.旋转链表:https://leetcode.cn/problems/rotate-list/
\n232.用栈实现队列:https://leetcode.cn/problems/implement-queue-using-stacks/
\n225.用队列实现栈:https://leetcode.cn/problems/implement-stack-using-queues/
\n347.前 K 个高频元素:https://leetcode.cn/problems/top-k-frequent-elements/
\n239.滑动窗口最大值:https://leetcode.cn/problems/sliding-window-maximum/
\n105.从前序与中序遍历构造二叉树:https://leetcode.cn/problems/construct-binary-tree-from-preorder-and-inorder-traversal/
\n117.填充每个节点的下一个右侧节点指针 II:https://leetcode.cn/problems/populating-next-right-pointers-in-each-node-ii
\n236.二叉树的最近公共祖先:https://leetcode.cn/problems/lowest-common-ancestor-of-a-binary-tree/
\n129.求根节点到叶节点数字之和:https://leetcode.cn/problems/sum-root-to-leaf-numbers/
\n102.二叉树的层序遍历:https://leetcode.cn/problems/binary-tree-level-order-traversal/
\n530.二叉搜索树的最小绝对差:https://leetcode.cn/problems/minimum-absolute-difference-in-bst/
\n200.岛屿数量:https://leetcode.cn/problems/number-of-islands/
\n207.课程表:https://leetcode.cn/problems/course-schedule/
\n210.课程表 II:https://leetcode.cn/problems/course-schedule-ii/
\n215.数组中的第 K 个最大元素:https://leetcode.cn/problems/kth-largest-element-in-an-array/
\n216.数据流的中位数:https://leetcode.cn/problems/find-median-from-data-stream/
\n217.前 K 个高频元素:https://leetcode.cn/problems/top-k-frequent-elements/
\n", "date_published": "2023-11-07T08:25:02.000Z", "date_modified": "2023-12-30T09:14:13.000Z", "authors": [], "tags": [ "计算机基础" ] }, { "title": "虚拟线程极简入门", "url": "https://javaguide.cn/java/concurrent/virtual-thread.html", "id": "https://javaguide.cn/java/concurrent/virtual-thread.html", "summary": " 本文部分内容来自 Lorin 的PR。 虚拟线程在 Java 21 正式发布,这是一项重量级的更新。 什么是虚拟线程? 虚拟线程(Virtual Thread)是 JDK 而不是 OS 实现的轻量级线程(Lightweight Process,LWP),由 JVM 调度。许多虚拟线程共享同一个操作系统线程,虚拟线程的数量可以远大于操作系统线程的数量。...", "content_html": "\n\n\n
虚拟线程在 Java 21 正式发布,这是一项重量级的更新。
\n虚拟线程(Virtual Thread)是 JDK 而不是 OS 实现的轻量级线程(Lightweight Process,LWP),由 JVM 调度。许多虚拟线程共享同一个操作系统线程,虚拟线程的数量可以远大于操作系统线程的数量。
\n在引入虚拟线程之前,java.lang.Thread
包已经支持所谓的平台线程(Platform Thread),也就是没有虚拟线程之前,我们一直使用的线程。JVM 调度程序通过平台线程(载体线程)来管理虚拟线程,一个平台线程可以在不同的时间执行不同的虚拟线程(多个虚拟线程挂载在一个平台线程上),当虚拟线程被阻塞或等待时,平台线程可以切换到执行另一个虚拟线程。
虚拟线程、平台线程和系统内核线程的关系图如下所示(图源:How to Use Java 19 Virtual Threads):
\n\n关于平台线程和系统内核线程的对应关系多提一点:在 Windows 和 Linux 等主流操作系统中,Java 线程采用的是一对一的线程模型,也就是一个平台线程对应一个系统内核线程。Solaris 系统是一个特例,HotSpot VM 在 Solaris 上支持多对多和一对一。具体可以参考 R 大的回答: JVM 中的线程模型是用户级的么?。
\nJava 21 已经正式支持虚拟线程,大家可以在官网下载使用,在使用上官方为了降低使用门槛,尽量复用原有的 Thread
类,让大家可以更加平滑的使用。
官方提供了以下四种方式创建虚拟线程:
\nThread.startVirtualThread()
创建Thread.ofVirtual()
创建ThreadFactory
创建public class VirtualThreadTest {\n public static void main(String[] args) {\n CustomThread customThread = new CustomThread();\n Thread.startVirtualThread(customThread);\n }\n}\n\nstatic class CustomThread implements Runnable {\n @Override\n public void run() {\n System.out.println(\"CustomThread run\");\n }\n}\n
public class VirtualThreadTest {\n public static void main(String[] args) {\n CustomThread customThread = new CustomThread();\n // 创建不启动\n Thread unStarted = Thread.ofVirtual().unstarted(customThread);\n unStarted.start();\n // 创建直接启动\n Thread.ofVirtual().start(customThread);\n }\n}\nstatic class CustomThread implements Runnable {\n @Override\n public void run() {\n System.out.println(\"CustomThread run\");\n }\n}\n
public class VirtualThreadTest {\n public static void main(String[] args) {\n CustomThread customThread = new CustomThread();\n ThreadFactory factory = Thread.ofVirtual().factory();\n Thread thread = factory.newThread(customThread);\n thread.start();\n }\n}\n\nstatic class CustomThread implements Runnable {\n @Override\n public void run() {\n System.out.println(\"CustomThread run\");\n }\n}\n
public class VirtualThreadTest {\n public static void main(String[] args) {\n CustomThread customThread = new CustomThread();\n ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();\n executor.submit(customThread);\n }\n}\nstatic class CustomThread implements Runnable {\n @Override\n public void run() {\n System.out.println(\"CustomThread run\");\n }\n}\n
通过多线程和虚拟线程的方式处理相同的任务,对比创建的系统线程数和处理耗时。
\n说明:统计创建的系统线程中部分为后台线程(比如 GC 线程),两种场景下都一样,所以并不影响对比。
\n测试代码:
\npublic class VirtualThreadTest {\n static List<Integer> list = new ArrayList<>();\n public static void main(String[] args) {\n // 开启线程 统计平台线程数\n ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(1);\n scheduledExecutorService.scheduleAtFixedRate(() -> {\n ThreadMXBean threadBean = ManagementFactory.getThreadMXBean();\n ThreadInfo[] threadInfo = threadBean.dumpAllThreads(false, false);\n updateMaxThreadNum(threadInfo.length);\n }, 10, 10, TimeUnit.MILLISECONDS);\n\n long start = System.currentTimeMillis();\n // 虚拟线程\n ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();\n // 使用平台线程\n // ExecutorService executor = Executors.newFixedThreadPool(200);\n for (int i = 0; i < 10000; i++) {\n executor.submit(() -> {\n try {\n // 线程睡眠 0.5 s,模拟业务处理\n TimeUnit.MILLISECONDS.sleep(500);\n } catch (InterruptedException ignored) {\n }\n });\n }\n executor.close();\n System.out.println(\"max:\" + list.get(0) + \" platform thread/os thread\");\n System.out.printf(\"totalMillis:%dms\\n\", System.currentTimeMillis() - start);\n\n\n }\n // 更新创建的平台最大线程数\n private static void updateMaxThreadNum(int num) {\n if (list.isEmpty()) {\n list.add(num);\n } else {\n Integer integer = list.get(0);\n if (num > integer) {\n list.add(0, num);\n }\n }\n }\n}\n
请求数 10000 单请求耗时 1s:
\n// Virtual Thread\nmax:22 platform thread/os thread\ntotalMillis:1806ms\n\n// Platform Thread 线程数200\nmax:209 platform thread/os thread\ntotalMillis:50578ms\n\n// Platform Thread 线程数500\nmax:509 platform thread/os thread\ntotalMillis:20254ms\n\n// Platform Thread 线程数1000\nmax:1009 platform thread/os thread\ntotalMillis:10214ms\n\n// Platform Thread 线程数2000\nmax:2009 platform thread/os thread\ntotalMillis:5358ms\n
请求数 10000 单请求耗时 0.5s:
\n// Virtual Thread\nmax:22 platform thread/os thread\ntotalMillis:1316ms\n\n// Platform Thread 线程数200\nmax:209 platform thread/os thread\ntotalMillis:25619ms\n\n// Platform Thread 线程数500\nmax:509 platform thread/os thread\ntotalMillis:10277ms\n\n// Platform Thread 线程数1000\nmax:1009 platform thread/os thread\ntotalMillis:5197ms\n\n// Platform Thread 线程数2000\nmax:2009 platform thread/os thread\ntotalMillis:2865ms\n
注意:有段时间 JDK 一直致力于 Reactor 响应式编程来提高 Java 性能,但响应式编程难以理解、调试、使用,最终又回到了同步编程,最终虚拟线程诞生。
\n", "image": "https://oss.javaguide.cn/github/javaguide/java/new-features/virtual-threads-platform-threads-kernel-threads-relationship.png", "date_published": "2023-10-15T12:01:30.000Z", "date_modified": "2023-10-26T22:44:02.000Z", "authors": [], "tags": [ "Java" ] }, { "title": "Java 21 新特性概览(重要)", "url": "https://javaguide.cn/java/new-features/java21.html", "id": "https://javaguide.cn/java/new-features/java21.html", "summary": "JDK 21 于 2023 年 9 月 19 日 发布,这是一个非常重要的版本,里程碑式。 JDK21 是 LTS(长期支持版),至此为止,目前有 JDK8、JDK11、JDK17 和 JDK21 这四个长期支持版了。 JDK 21 共有 15 个新特性,这篇文章会挑选其中较为重要的一些新特性进行详细介绍: JEP 430:String Templat...", "content_html": "JDK 21 于 2023 年 9 月 19 日 发布,这是一个非常重要的版本,里程碑式。
\nJDK21 是 LTS(长期支持版),至此为止,目前有 JDK8、JDK11、JDK17 和 JDK21 这四个长期支持版了。
\nJDK 21 共有 15 个新特性,这篇文章会挑选其中较为重要的一些新特性进行详细介绍:
\nJEP 445:Unnamed Classes and Instance Main Methods(未命名类和实例 main 方法 )(预览)
\nString Templates(字符串模板) 目前仍然是 JDK 21 中的一个预览功能。
\nString Templates 提供了一种更简洁、更直观的方式来动态构建字符串。通过使用占位符${}
,我们可以将变量的值直接嵌入到字符串中,而不需要手动处理。在运行时,Java 编译器会将这些占位符替换为实际的变量值。并且,表达式支持局部变量、静态/非静态字段甚至方法、计算结果等特性。
实际上,String Templates(字符串模板)再大多数编程语言中都存在:
\n\"Greetings {{ name }}!\"; //Angular\n`Greetings ${ name }!`; //Typescript\n$\"Greetings { name }!\" //Visual basic\nf\"Greetings { name }!\" //Python\n
Java 在没有 String Templates 之前,我们通常使用字符串拼接或格式化方法来构建字符串:
\n//concatenation\nmessage = \"Greetings \" + name + \"!\";\n\n//String.format()\nmessage = String.format(\"Greetings %s!\", name); //concatenation\n\n//MessageFormat\nmessage = new MessageFormat(\"Greetings {0}!\").format(name);\n\n//StringBuilder\nmessage = new StringBuilder().append(\"Greetings \").append(name).append(\"!\").toString();\n
这些方法或多或少都存在一些缺点,比如难以阅读、冗长、复杂。
\nJava 使用 String Templates 进行字符串拼接,可以直接在字符串中嵌入表达式,而无需进行额外的处理:
\nString message = STR.\"Greetings \\{name}!\";\n
在上面的模板表达式中:
\n\\{name}
为表达式,运行时,这些表达式将被相应的变量值替换。Java 目前支持三种模板处理器:
\nStringTemplate
对象,这个对象包含了模板中的文本和表达式的信息String name = \"Lokesh\";\n\n//STR\nString message = STR.\"Greetings \\{name}.\";\n\n//FMT\nString message = STR.\"Greetings %-12s\\{name}.\";\n\n//RAW\nStringTemplate st = RAW.\"Greetings \\{name}.\";\nString message = STR.process(st);\n
除了 JDK 自带的三种模板处理器外,你还可以实现 StringTemplate.Processor
接口来创建自己的模板处理器。
我们可以使用局部变量、静态/非静态字段甚至方法作为嵌入表达式:
\n//variable\nmessage = STR.\"Greetings \\{name}!\";\n\n//method\nmessage = STR.\"Greetings \\{getName()}!\";\n\n//field\nmessage = STR.\"Greetings \\{this.name}!\";\n
还可以在表达式中执行计算并打印结果:
\nint x = 10, y = 20;\nString s = STR.\"\\{x} + \\{y} = \\{x + y}\"; //\"10 + 20 = 30\"\n
为了提高可读性,我们可以将嵌入的表达式分成多行:
\nString time = STR.\"The current time is \\{\n //sample comment - current time in HH:mm:ss\n DateTimeFormatter\n .ofPattern(\"HH:mm:ss\")\n .format(LocalTime.now())\n }.\";\n
JDK 21 引入了一种新的集合类型:Sequenced Collections(序列化集合,也叫有序集合),这是一种具有确定出现顺序(encounter order)的集合(无论我们遍历这样的集合多少次,元素的出现顺序始终是固定的)。序列化集合提供了处理集合的第一个和最后一个元素以及反向视图(与原始集合相反的顺序)的简单方法。
\nSequenced Collections 包括以下三个接口:
\n\nSequencedCollection
接口继承了 Collection
接口, 提供了在集合两端访问、添加或删除元素以及获取集合的反向视图的方法。
interface SequencedCollection<E> extends Collection<E> {\n\n // New Method\n\n SequencedCollection<E> reversed();\n\n // Promoted methods from Deque<E>\n\n void addFirst(E);\n void addLast(E);\n\n E getFirst();\n E getLast();\n\n E removeFirst();\n E removeLast();\n}\n
List
和 Deque
接口实现了SequencedCollection
接口。
这里以 ArrayList
为例,演示一下实际使用效果:
ArrayList<Integer> arrayList = new ArrayList<>();\n\narrayList.add(1); // List contains: [1]\n\narrayList.addFirst(0); // List contains: [0, 1]\narrayList.addLast(2); // List contains: [0, 1, 2]\n\nInteger firstElement = arrayList.getFirst(); // 0\nInteger lastElement = arrayList.getLast(); // 2\n\nList<Integer> reversed = arrayList.reversed();\nSystem.out.println(reversed); // Prints [2, 1, 0]\n
SequencedSet
接口直接继承了 SequencedCollection
接口并重写了 reversed()
方法。
interface SequencedSet<E> extends SequencedCollection<E>, Set<E> {\n\n SequencedSet<E> reversed();\n}\n
SortedSet
和 LinkedHashSet
实现了SequencedSet
接口。
这里以 LinkedHashSet
为例,演示一下实际使用效果:
LinkedHashSet<Integer> linkedHashSet = new LinkedHashSet<>(List.of(1, 2, 3));\n\nInteger firstElement = linkedHashSet.getFirst(); // 1\nInteger lastElement = linkedHashSet.getLast(); // 3\n\nlinkedHashSet.addFirst(0); //List contains: [0, 1, 2, 3]\nlinkedHashSet.addLast(4); //List contains: [0, 1, 2, 3, 4]\n\nSystem.out.println(linkedHashSet.reversed()); //Prints [5, 3, 2, 1, 0]\n
SequencedMap
接口继承了 Map
接口, 提供了在集合两端访问、添加或删除键值对、获取包含 key 的 SequencedSet
、包含 value 的 SequencedCollection
、包含 entry(键值对) 的 SequencedSet
以及获取集合的反向视图的方法。
interface SequencedMap<K,V> extends Map<K,V> {\n\n // New Methods\n\n SequencedMap<K,V> reversed();\n\n SequencedSet<K> sequencedKeySet();\n SequencedCollection<V> sequencedValues();\n SequencedSet<Entry<K,V>> sequencedEntrySet();\n\n V putFirst(K, V);\n V putLast(K, V);\n\n\n // Promoted Methods from NavigableMap<K, V>\n\n Entry<K, V> firstEntry();\n Entry<K, V> lastEntry();\n\n Entry<K, V> pollFirstEntry();\n Entry<K, V> pollLastEntry();\n}\n
SortedMap
和LinkedHashMap
实现了SequencedMap
接口。
这里以 LinkedHashMap
为例,演示一下实际使用效果:
LinkedHashMap<Integer, String> map = new LinkedHashMap<>();\n\nmap.put(1, \"One\");\nmap.put(2, \"Two\");\nmap.put(3, \"Three\");\n\nmap.firstEntry(); //1=One\nmap.lastEntry(); //3=Three\n\nSystem.out.println(map); //{1=One, 2=Two, 3=Three}\n\nMap.Entry<Integer, String> first = map.pollFirstEntry(); //1=One\nMap.Entry<Integer, String> last = map.pollLastEntry(); //3=Three\n\nSystem.out.println(map); //{2=Two}\n\nmap.putFirst(1, \"One\"); //{1=One, 2=Two}\nmap.putLast(3, \"Three\"); //{1=One, 2=Two, 3=Three}\n\nSystem.out.println(map); //{1=One, 2=Two, 3=Three}\nSystem.out.println(map.reversed()); //{3=Three, 2=Two, 1=One}\n
JDK21 中对 ZGC 进行了功能扩展,增加了分代 GC 功能。不过,默认是关闭的,需要通过配置打开:
\n// 启用分代ZGC\njava -XX:+UseZGC -XX:+ZGenerational ...\n
在未来的版本中,官方会把 ZGenerational 设为默认值,即默认打开 ZGC 的分代 GC。在更晚的版本中,非分代 ZGC 就被移除。
\n\n\nIn a future release we intend to make Generational ZGC the default, at which point -XX:-ZGenerational will select non-generational ZGC. In an even later release we intend to remove non-generational ZGC, at which point the ZGenerational option will become obsolete.
\n在将来的版本中,我们打算将 Generational ZGC 作为默认选项,此时-XX:-ZGenerational 将选择非分代 ZGC。在更晚的版本中,我们打算移除非分代 ZGC,此时 ZGenerational 选项将变得过时。
\n
分代 ZGC 可以显著减少垃圾回收过程中的停顿时间,并提高应用程序的响应性能。这对于大型 Java 应用程序和高并发场景下的性能优化非常有价值。
\n记录模式在 Java 19 进行了第一次预览, 由 JEP 405 提出。JDK 20 中是第二次预览,由 JEP 432 提出。最终,记录模式在 JDK21 顺利转正。
\nJava 20 新特性概览已经详细介绍过记录模式,这里就不重复了。
\n增强 Java 中的 switch 表达式和语句,允许在 case 标签中使用模式。当模式匹配时,执行 case 标签对应的代码。
\n在下面的代码中,switch 表达式使用了类型模式来进行匹配。
\nstatic String formatterPatternSwitch(Object obj) {\n return switch (obj) {\n case Integer i -> String.format(\"int %d\", i);\n case Long l -> String.format(\"long %d\", l);\n case Double d -> String.format(\"double %f\", d);\n case String s -> String.format(\"String %s\", s);\n default -> obj.toString();\n };\n}\n
Java 程序可以通过该 API 与 Java 运行时之外的代码和数据进行互操作。通过高效地调用外部函数(即 JVM 之外的代码)和安全地访问外部内存(即不受 JVM 管理的内存),该 API 使 Java 程序能够调用本机库并处理本机数据,而不会像 JNI 那样危险和脆弱。
\n外部函数和内存 API 在 Java 17 中进行了第一轮孵化,由 JEP 412 提出。Java 18 中进行了第二次孵化,由JEP 419 提出。Java 19 中是第一次预览,由 JEP 424 提出。JDK 20 中是第二次预览,由 JEP 434 提出。JDK 21 中是第三次预览,由 JEP 442 提出。
\n在 Java 19 新特性概览 中,我有详细介绍到外部函数和内存 API,这里就不再做额外的介绍了。
\n未命名模式和变量使得我们可以使用下划线 _
表示未命名的变量以及模式匹配时不使用的组件,旨在提高代码的可读性和可维护性。
未命名变量的典型场景是 try-with-resources
语句、 catch
子句中的异常变量和for
循环。当变量不需要使用的时候就可以使用下划线 _
代替,这样清晰标识未被使用的变量。
try (var _ = ScopedContext.acquire()) {\n // No use of acquired resource\n}\ntry { ... }\ncatch (Exception _) { ... }\ncatch (Throwable _) { ... }\n\nfor (int i = 0, _ = runOnce(); i < arr.length; i++) {\n ...\n}\n
未命名模式是一个无条件的模式,并不绑定任何值。未命名模式变量出现在类型模式中。
\nif (r instanceof ColoredPoint(_, Color c)) { ... c ... }\n\nswitch (b) {\n case Box(RedBall _), Box(BlueBall _) -> processBox(b);\n case Box(GreenBall _) -> stopProcessing();\n case Box(_) -> pickAnotherBox();\n}\n
虚拟线程是一项重量级的更新,一定一定要重视!
\n虚拟线程在 Java 19 中进行了第一次预览,由JEP 425提出。JDK 20 中是第二次预览。最终,虚拟线程在 JDK21 顺利转正。
\nJava 20 新特性概览已经详细介绍过虚拟线程,这里就不重复了。
\n这个特性主要简化了 main
方法的的声明。对于 Java 初学者来说,这个 main
方法的声明引入了太多的 Java 语法概念,不利于初学者快速上手。
没有使用该特性之前定义一个 main
方法:
public class HelloWorld {\n public static void main(String[] args) {\n System.out.println(\"Hello, World!\");\n }\n}\n
使用该新特性之后定义一个 main
方法:
class HelloWorld {\n void main() {\n System.out.println(\"Hello, World!\");\n }\n}\n
进一步精简(未命名的类允许我们不定义类名):
\nvoid main() {\n System.out.println(\"Hello, World!\");\n}\n
\n\n本文由 JavaGuide 翻译并完善,原文地址:https://medium.com/@AlexanderObregon/maven-best-practices-tips-and-tricks-for-java-developers-438eca03f72b 。
\n
Maven 是一种广泛使用的 Java 项目构建自动化工具。它简化了构建过程并帮助管理依赖关系,使开发人员的工作更轻松。Maven 详细介绍可以参考我写的这篇 Maven 核心概念总结 。
\n这篇文章不会涉及到 Maven 概念的介绍,主要讨论一些最佳实践、建议和技巧,以优化我们在项目中对 Maven 的使用并改善我们的开发体验。
\nMaven 遵循标准目录结构来保持项目之间的一致性。遵循这种结构可以让其他开发人员更轻松地理解我们的项目。
\nMaven 项目的标准目录结构如下:
\nsrc /\n main /\n java/\n resources/\n test/ java\n /\n resources/\npom.xml\n
src/main/java
:源代码目录src/main/resources
:资源文件目录src/test/java
:测试代码目录src/test/resources
:测试资源文件目录这只是一个最简单的 Maven 项目目录示例。实际项目中,我们还会根据项目规范去做进一步的细分。
\n默认情况下,Maven 使用 Java5 编译我们的项目。要使用不同的 JDK 版本,请在 pom.xml
文件中配置 Maven 编译器插件。
例如,如果你想要使用 Java8 来编译你的项目,你可以在<build>
标签下添加以下的代码片段:
<build>\n <plugins>\n <plugin>\n <groupId>org.apache.maven.plugins</groupId>\n <artifactId>maven-compiler-plugin</artifactId>\n <version>3.8.1</version>\n <configuration>\n <source>1.8</source>\n <target>1.8</target>\n </configuration>\n </plugin>\n </plugins>\n</build>\n
这样,Maven 就会使用 Java8 的编译器来编译你的项目。如果你想要使用其他版本的 JDK,你只需要修改<source>
和<target>
标签的值即可。例如,如果你想要使用 Java11,你可以将它们的值改为 11。
Maven 的依赖管理系统是其最强大的功能之一。在顶层 pom 文件中,通过标签 dependencyManagement
定义公共的依赖关系,这有助于避免冲突并确保所有模块使用相同版本的依赖项。
例如,假设我们有一个父模块和两个子模块 A 和 B,我们想要在所有模块中使用 JUnit 5.7.2 作为测试框架。我们可以在父模块的pom.xml
文件中使用<dependencyManagement>
标签来定义 JUnit 的版本:
<dependencyManagement>\n <dependencies>\n <dependency>\n <groupId>org.junit.jupiter</groupId>\n <artifactId>junit-jupiter</artifactId>\n <version>5.7.2</version>\n <scope>test</scope>\n </dependency>\n </dependencies>\n</dependencyManagement>\n
在子模块 A 和 B 的 pom.xml
文件中,我们只需要引用 JUnit 的 groupId
和 artifactId
即可:
<dependencies>\n <dependency>\n <groupId>org.junit.jupiter</groupId>\n <artifactId>junit-jupiter</artifactId>\n </dependency>\n</dependencies>\n
Maven 配置文件允许我们配置不同环境的构建设置,例如开发、测试和生产。在 pom.xml
文件中定义配置文件并使用命令行参数激活它们:
<profiles>\n <profile>\n <id>development</id>\n <activation>\n <activeByDefault>true</activeByDefault>\n </activation>\n <properties>\n <environment>dev</environment>\n </properties>\n </profile>\n <profile>\n <id>production</id>\n <properties>\n <environment>prod</environment>\n </properties>\n </profile>\n</profiles>\n
使用命令行激活配置文件:
\nmvn clean install -P production\n
组织良好的 pom.xml
文件更易于维护和理解。以下是维护干净的 pom.xml
的一些技巧:
<properties>
标签内以便于管理。<properties>\n <junit.version>5.7.0</junit.version>\n <mockito.version>3.9.0</mockito.version>\n</properties>\n
Maven Wrapper 是一个用于管理和使用 Maven 的工具,它允许在没有预先安装 Maven 的情况下运行和构建 Maven 项目。
\nMaven 官方文档是这样介绍 Maven Wrapper 的:
\n\n\nThe Maven Wrapper is an easy way to ensure a user of your Maven build has everything necessary to run your Maven build.
\nMaven Wrapper 是一种简单的方法,可以确保 Maven 构建的用户拥有运行 Maven 构建所需的一切。
\n
Maven Wrapper 可以确保构建过程使用正确的 Maven 版本,非常方便。要使用 Maven Wrapper,请在项目目录中运行以下命令:
\nmvn wrapper:wrapper\n
此命令会在我们的项目中生成 Maven Wrapper 文件。现在我们可以使用 ./mvnw
(或 Windows 上的 ./mvnw.cmd
)而不是 mvn
来执行 Maven 命令。
将 Maven 项目与持续集成 (CI) 系统(例如 Jenkins 或 GitHub Actions)集成,可确保自动构建、测试和部署我们的代码。CI 有助于及早发现问题并在整个团队中提供一致的构建流程。以下是 Maven 项目的简单 GitHub Actions 工作流程示例:
\nname: Java CI with Maven\n\non: [push]\n\njobs:\n build:\n runs-on: ubuntu-latest\n\n steps:\n - name: Checkout code\n uses: actions/checkout@v2\n\n - name: Set up JDK 11\n uses: actions/setup-java@v2\n with:\n java-version: '11'\n distribution: 'adopt'\n\n - name: Build with Maven\n run: ./mvnw clean install\n
有许多 Maven 插件可用于扩展 Maven 的功能。一些流行的插件包括(前三个是 Maven 自带的插件,后三个是第三方提供的插件):
\njacoco-maven-plugin 使用示例:
\n<build>\n <plugins>\n <plugin>\n <groupId>org.jacoco</groupId>\n <artifactId>jacoco-maven-plugin</artifactId>\n <version>0.8.8</version>\n <executions>\n <execution>\n <goals>\n <goal>prepare-agent</goal>\n </goals>\n </execution>\n <execution>\n <id>generate-code-coverage-report</id>\n <phase>test</phase>\n <goals>\n <goal>report</goal>\n </goals>\n </execution>\n </executions>\n </plugin>\n </plugins>\n</build>\n
如果这些已有的插件无法满足我们的需求,我们还可以自定义插件。
\n探索可用的插件并在 pom.xml
文件中配置它们以增强我们的开发过程。
Maven 是一个强大的工具,可以简化 Java 项目的构建过程和依赖关系管理。通过遵循这些最佳实践和技巧,我们可以优化 Maven 的使用并改善我们的 Java 开发体验。请记住使用标准目录结构,有效管理依赖关系,利用不同环境的配置文件,并将项目与持续集成系统集成,以确保构建一致。
\n", "date_published": "2023-09-15T13:14:19.000Z", "date_modified": "2023-10-26T22:44:02.000Z", "authors": [], "tags": [ "开发工具" ] }, { "title": "IoC & AOP详解(快速搞懂)", "url": "https://javaguide.cn/system-design/framework/spring/ioc-and-aop.html", "id": "https://javaguide.cn/system-design/framework/spring/ioc-and-aop.html", "summary": "这篇文章会从下面从以下几个问题展开对 IoC & AOP 的解释 什么是 IoC? IoC 解决了什么问题? IoC 和 DI 的区别? 什么是 AOP? AOP 解决了什么问题? AOP 的应用场景有哪些? AOP 为什么叫做切面编程? AOP 实现方式有哪些? 首先声明:IoC & AOP 不是 Spring 提出来的,它们在 Spring 之前其...", "content_html": "这篇文章会从下面从以下几个问题展开对 IoC & AOP 的解释
\n首先声明:IoC & AOP 不是 Spring 提出来的,它们在 Spring 之前其实已经存在了,只不过当时更加偏向于理论。Spring 在技术层次将这两个思想进行了很好的实现。
\nIoC (Inversion of Control )即控制反转/反转控制。它是一种思想不是一个技术实现。描述的是:Java 开发领域对象的创建以及管理的问题。
\n例如:现有类 A 依赖于类 B
\n从以上两种开发方式的对比来看:我们 “丧失了一个权力” (创建、管理对象的权力),从而也得到了一个好处(不用再考虑对象的创建、管理等一系列的事情)
\n为什么叫控制反转?
\nIoC 的思想就是两方之间不互相依赖,由第三方容器来管理相关资源。这样有什么好处呢?
\n例如:现有一个针对 User 的操作,利用 Service 和 Dao 两层结构进行开发
\n在没有使用 IoC 思想的情况下,Service 层想要使用 Dao 层的具体实现的话,需要通过 new 关键字在UserServiceImpl
中手动 new 出 IUserDao
的具体实现类 UserDaoImpl
(不能直接 new 接口类)。
很完美,这种方式也是可以实现的,但是我们想象一下如下场景:
\n开发过程中突然接到一个新的需求,针对IUserDao
接口开发出另一个具体实现类。因为 Server 层依赖了IUserDao
的具体实现,所以我们需要修改UserServiceImpl
中 new 的对象。如果只有一个类引用了IUserDao
的具体实现,可能觉得还好,修改起来也不是很费力气,但是如果有许许多多的地方都引用了IUserDao
的具体实现的话,一旦需要更换IUserDao
的实现方式,那修改起来将会非常的头疼。
使用 IoC 的思想,我们将对象的控制权(创建、管理)交有 IoC 容器去管理,我们在使用的时候直接向 IoC 容器 “要” 就可以了
\n\nIoC(Inverse of Control:控制反转)是一种设计思想或者说是某种模式。这个设计思想就是 将原本在程序中手动创建对象的控制权交给第三方比如 IoC 容器。 对于我们常用的 Spring 框架来说, IoC 容器实际上就是个 Map(key,value),Map 中存放的是各种对象。不过,IoC 在其他语言中也有应用,并非 Spring 特有。
\nIoC 最常见以及最合理的实现方式叫做依赖注入(Dependency Injection,简称 DI)。
\n老马(Martin Fowler)在一篇文章中提到将 IoC 改名为 DI,原文如下,原文地址:https://martinfowler.com/articles/injection.html 。
\n\n老马的大概意思是 IoC 太普遍并且不表意,很多人会因此而迷惑,所以,使用 DI 来精确指名这个模式比较好。
\n这里不会涉及太多专业的术语,核心目的是将 AOP 的思想说清楚。
\nAOP(Aspect Oriented Programming)即面向切面编程,AOP 是 OOP(面向对象编程)的一种延续,二者互补,并不对立。
\nAOP 的目的是将横切关注点(如日志记录、事务管理、权限控制、接口限流、接口幂等等)从核心业务逻辑中分离出来,通过动态代理、字节码操作等技术,实现代码的复用和解耦,提高代码的可维护性和可扩展性。OOP 的目的是将业务逻辑按照对象的属性和行为进行封装,通过类、对象、继承、多态等概念,实现代码的模块化和层次化(也能实现代码的复用),提高代码的可读性和可维护性。
\nAOP 之所以叫面向切面编程,是因为它的核心思想就是将横切关注点从核心业务逻辑中分离出来,形成一个个的切面(Aspect)。
\n这里顺带总结一下 AOP 关键术语(不理解也没关系,可以继续往下看):
\nexecution(* com.xyz.service..*(..))
匹配 com.xyz.service
包及其子包下的类或接口。OOP 不能很好地处理一些分散在多个类或对象中的公共行为(如日志记录、事务管理、权限控制、接口限流、接口幂等等),这些行为通常被称为 横切关注点(cross-cutting concerns) 。如果我们在每个类或对象中都重复实现这些行为,那么会导致代码的冗余、复杂和难以维护。
\nAOP 可以将横切关注点(如日志记录、事务管理、权限控制、接口限流、接口幂等等)从 核心业务逻辑(core concerns,核心关注点) 中分离出来,实现关注点的分离。
\n\n以日志记录为例进行介绍,假如我们需要对某些方法进行统一格式的日志记录,没有使用 AOP 技术之前,我们需要挨个写日志记录的逻辑代码,全是重复的的逻辑。
\npublic CommonResponse<Object> method1() {\n // 业务逻辑\n xxService.method1();\n // 省略具体的业务处理逻辑\n // 日志记录\n ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();\n HttpServletRequest request = attributes.getRequest();\n // 省略记录日志的具体逻辑 如:获取各种信息,写入数据库等操作...\n return CommonResponse.success();\n}\n\npublic CommonResponse<Object> method2() {\n // 业务逻辑\n xxService.method2();\n // 省略具体的业务处理逻辑\n // 日志记录\n ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();\n HttpServletRequest request = attributes.getRequest();\n // 省略记录日志的具体逻辑 如:获取各种信息,写入数据库等操作...\n return CommonResponse.success();\n}\n\n// ...\n
使用 AOP 技术之后,我们可以将日志记录的逻辑封装成一个切面,然后通过切入点和通知来指定在哪些方法需要执行日志记录的操作。
\n\n// 日志注解\n@Target({ElementType.PARAMETER,ElementType.METHOD})\n@Retention(RetentionPolicy.RUNTIME)\n@Documented\npublic @interface Log {\n\n /**\n * 描述\n */\n String description() default \"\";\n\n /**\n * 方法类型 INSERT DELETE UPDATE OTHER\n */\n MethodType methodType() default MethodType.OTHER;\n}\n\n// 日志切面\n@Component\n@Aspect\npublic class LogAspect {\n // 切入点,所有被 Log 注解标注的方法\n @Pointcut(\"@annotation(cn.javaguide.annotation.Log)\")\n public void webLog() {\n }\n\n /**\n * 环绕通知\n */\n @Around(\"webLog()\")\n public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable {\n // 省略具体的处理逻辑\n }\n\n // 省略其他代码\n}\n
这样的话,我们一行注解即可实现日志记录:
\n@Log(description = \"method1\",methodType = MethodType.INSERT)\npublic CommonResponse<Object> method1() {\n // 业务逻辑\n xxService.method1();\n // 省略具体的业务处理逻辑\n return CommonResponse.success();\n}\n
@Transactional
注解可以让 Spring 为我们进行事务管理比如回滚异常操作,免去了重复的事务管理逻辑。@Transactional
注解就是基于 AOP 实现的。@PreAuthorize
注解一行代码即可自定义权限校验。AOP 的常见实现方式有动态代理、字节码操作等方式。
\nSpring AOP 就是基于动态代理的,如果要代理的对象,实现了某个接口,那么 Spring AOP 会使用 JDK Proxy,去创建代理对象,而对于没有实现接口的对象,就无法使用 JDK Proxy 去进行代理了,这时候 Spring AOP 会使用 Cglib 生成一个被代理对象的子类来作为代理,如下图所示:
\n\n当然你也可以使用 AspectJ !Spring AOP 已经集成了 AspectJ ,AspectJ 应该算的上是 Java 生态系统中最完整的 AOP 框架了。
\nSpring AOP 属于运行时增强,而 AspectJ 是编译时增强。 Spring AOP 基于代理(Proxying),而 AspectJ 基于字节码操作(Bytecode Manipulation)。
\nSpring AOP 已经集成了 AspectJ ,AspectJ 应该算的上是 Java 生态系统中最完整的 AOP 框架了。AspectJ 相比于 Spring AOP 功能更加强大,但是 Spring AOP 相对来说更简单,
\n如果我们的切面比较少,那么两者性能差异不大。但是,当切面太多的话,最好选择 AspectJ ,它比 Spring AOP 快很多。
\n", "image": "https://oss.javaguide.cn/java-guide-blog/frc-365faceb5697f04f31399937c059c162.png", "date_published": "2023-09-10T14:59:47.000Z", "date_modified": "2024-02-16T02:58:48.000Z", "authors": [], "tags": [ "框架" ] }, { "title": "Spring Boot核心源码解读(付费)", "url": "https://javaguide.cn/system-design/framework/spring/springboot-source-code.html", "id": "https://javaguide.cn/system-design/framework/spring/springboot-source-code.html", "summary": "Spring Boot 核心源码解读 为我的知识星球(点击链接即可查看详细介绍以及加入方法)专属内容,已经整理到了《Java 必读源码系列》中。 Spring Boot核心源码解读Spring Boot核心源码解读 (点击链接即可查看详细介绍)的部分内容展示如下。 《Java 必读源码系列》《Java 必读源码系列》 为了帮助更多同学准备 Java 面...", "content_html": "Spring Boot 核心源码解读 为我的知识星球(点击链接即可查看详细介绍以及加入方法)专属内容,已经整理到了《Java 必读源码系列》中。
\n\n《Java 必读源码系列》(点击链接即可查看详细介绍)的部分内容展示如下。
\n\n为了帮助更多同学准备 Java 面试以及学习 Java ,我创建了一个纯粹的Java 面试知识星球。虽然收费只有培训班/训练营的百分之一,但是知识星球里的内容质量更高,提供的服务也更全面,非常适合准备 Java 面试和学习 Java 的同学。
\n欢迎准备 Java 面试以及学习 Java 的同学加入我的 知识星球,干货非常多,学习氛围也很不错!收费虽然是白菜价,但星球里的内容或许比你参加上万的培训班质量还要高。
\n下面是星球提供的部分服务(点击下方图片即可获取知识星球的详细介绍):
\n\n我有自己的原则,不割韭菜,用心做内容,真心希望帮助到你!
\n如果你感兴趣的话,不妨花 3 分钟左右看看星球的详细介绍:JavaGuide 知识星球详细介绍 。
\n这里再送一个 30 元的星球专属优惠券,数量有限(价格即将上调。老用户续费半价 ,微信扫码即可续费)!
\n\n进入星球之后,记得查看 星球使用指南 (一定要看!!!) 和 星球优质主题汇总 。
\n无任何套路,无任何潜在收费项。用心做内容,不割韭菜!
\n不过, 一定要确定需要再进 。并且, 三天之内觉得内容不满意可以全额退款 。
\n\n", "image": "https://oss.javaguide.cn/xingqiu/springboot-source-code.png", "date_published": "2023-07-27T01:08:19.000Z", "date_modified": "2023-10-26T22:44:02.000Z", "authors": [], "tags": [ "框架" ] }, { "title": "SQL常见面试题总结(2)", "url": "https://javaguide.cn/database/sql/sql-questions-02.html", "id": "https://javaguide.cn/database/sql/sql-questions-02.html", "summary": " 题目来源于:牛客题霸 - SQL 进阶挑战 增删改操作 SQL 插入记录的方式汇总: 普通插入(全字段) :INSERT INTO table_name VALUES (value1, value2, ...) 普通插入(限定字段) :INSERT INTO table_name (column1, column2, ...) VALUES (val...", "content_html": "\n\n题目来源于:牛客题霸 - SQL 进阶挑战
\n
SQL 插入记录的方式汇总:
\nINSERT INTO table_name VALUES (value1, value2, ...)
INSERT INTO table_name (column1, column2, ...) VALUES (value1, value2, ...)
INSERT INTO table_name (column1, column2, ...) VALUES (value1_1, value1_2, ...), (value2_1, value2_2, ...), ...
INSERT INTO table_name SELECT * FROM table_name2 [WHERE key=value]
REPLACE INTO table_name VALUES (value1, value2, ...)
(注意这种原理是检测到主键或唯一性索引键重复就删除原记录后重新插入)描述:牛客后台会记录每个用户的试卷作答记录到 exam_record
表,现在有两个用户的作答记录详情如下:
试卷作答记录表exam_record
中,表已建好,其结构如下,请用一条语句将这两条记录插入表中。
| Filed | Type | Null | Key | Extra | Default | Comment |
\n|
\n\n题目来源于:牛客题霸 - SQL 进阶挑战
\n
较难或者困难的题目可以根据自身实际情况和面试需要来决定是否要跳过。
\n描述: 牛客的运营同学想要查看大家在 SQL 类别中高难度试卷的得分情况。
\n请你帮她从exam_record
数据表中计算所有用户完成 SQL 类别高难度试卷得分的截断平均值(去掉一个最大值和一个最小值后的平均值)。
示例数据:examination_info
(exam_id
试卷 ID, tag 试卷类别, difficulty
试卷难度, duration
考试时长, release_time
发布时间)
| id | exam_id | tag | difficulty | duration | release_time |
\n|
\n\n题目来源于:牛客题霸 - SQL 进阶挑战
\n
较难或者困难的题目可以根据自身实际情况和面试需要来决定是否要跳过。
\nMySQL 8.0 版本引入了窗口函数的支持,下面是 MySQL 中常见的窗口函数及其用法:
\nROW_NUMBER()
: 为查询结果集中的每一行分配一个唯一的整数值。SELECT col1, col2, ROW_NUMBER() OVER (ORDER BY col1) AS row_num\nFROM table;\n
RANK()
: 计算每一行在排序结果中的排名。SELECT col1, col2, RANK() OVER (ORDER BY col1 DESC) AS ranking\nFROM table;\n
DENSE_RANK()
: 计算每一行在排序结果中的排名,保留相同的排名。SELECT col1, col2, DENSE_RANK() OVER (ORDER BY col1 DESC) AS ranking\nFROM table;\n
NTILE(n)
: 将结果分成 n 个基本均匀的桶,并为每个桶分配一个标识号。SELECT col1, col2, NTILE(4) OVER (ORDER BY col1) AS bucket\nFROM table;\n
SUM()
, AVG()
,COUNT()
, MIN()
, MAX()
: 这些聚合函数也可以与窗口函数结合使用,计算窗口内指定列的汇总、平均值、计数、最小值和最大值。SELECT col1, col2, SUM(col1) OVER () AS sum_col\nFROM table;\n
LEAD()
和 LAG()
: LEAD 函数用于获取当前行之后的某个偏移量的行的值,而 LAG 函数用于获取当前行之前的某个偏移量的行的值。SELECT col1, col2, LEAD(col1, 1) OVER (ORDER BY col1) AS next_col1,\n LAG(col1, 1) OVER (ORDER BY col1) AS prev_col1\nFROM table;\n
FIRST_VALUE()
和 LAST_VALUE()
: FIRST_VALUE 函数用于获取窗口内指定列的第一个值,LAST_VALUE 函数用于获取窗口内指定列的最后一个值。SELECT col1, col2, FIRST_VALUE(col2) OVER (PARTITION BY col1 ORDER BY col2) AS first_val,\n LAST_VALUE(col2) OVER (PARTITION BY col1 ORDER BY col2) AS last_val\nFROM table;\n
窗口函数通常需要配合 OVER 子句一起使用,用于定义窗口的大小、排序规则和分组方式。
\n描述:
\n现有试卷信息表 examination_info
(exam_id
试卷 ID, tag
试卷类别, difficulty
试卷难度, duration
考试时长, release_time
发布时间):
| id | exam_id | tag | difficulty | duration | release_time |
\n|
\n\n题目来源于:牛客题霸 - SQL 进阶挑战
\n
较难或者困难的题目可以根据自身实际情况和面试需要来决定是否要跳过。
\n描述:
\n现有试卷作答记录表 exam_record
(uid
用户 ID, exam_id
试卷 ID, start_time
开始作答时间, submit_time
交卷时间, score
得分),数据如下:
| id | uid | exam_id | start_time | submit_time | score |
\n|
LinkedHashMap
是 Java 提供的一个集合类,它继承自 HashMap
,并在 HashMap
基础上维护一条双向链表,使得具备如下特性:
LinkedHashMap
逻辑结构如下图所示,它是在 HashMap
基础上在各个节点之间维护一条双向链表,使得原本散列在不同 bucket 上的节点、链表、红黑树有序关联起来。
如下所示,我们按照顺序往 LinkedHashMap
添加元素然后进行遍历。
HashMap < String, String > map = new LinkedHashMap < > ();\nmap.put(\"a\", \"2\");\nmap.put(\"g\", \"3\");\nmap.put(\"r\", \"1\");\nmap.put(\"e\", \"23\");\n\nfor (Map.Entry < String, String > entry: map.entrySet()) {\n System.out.println(entry.getKey() + \":\" + entry.getValue());\n}\n
输出:
\na:2\ng:3\nr:1\ne:23\n
可以看出,LinkedHashMap
的迭代顺序是和插入顺序一致的,这一点是 HashMap
所不具备的。
LinkedHashMap
定义了排序模式 accessOrder
(boolean 类型,默认为 false),访问顺序则为 true,插入顺序则为 false。
为了实现访问顺序遍历,我们可以使用传入 accessOrder
属性的 LinkedHashMap
构造方法,并将 accessOrder
设置为 true,表示其具备访问有序性。
LinkedHashMap<Integer, String> map = new LinkedHashMap<>(16, 0.75f, true);\nmap.put(1, \"one\");\nmap.put(2, \"two\");\nmap.put(3, \"three\");\nmap.put(4, \"four\");\nmap.put(5, \"five\");\n//访问元素2,该元素会被移动至链表末端\nmap.get(2);\n//访问元素3,该元素会被移动至链表末端\nmap.get(3);\nfor (Map.Entry<Integer, String> entry : map.entrySet()) {\n System.out.println(entry.getKey() + \" : \" + entry.getValue());\n}\n
输出:
\n1 : one\n4 : four\n5 : five\n2 : two\n3 : three\n
可以看出,LinkedHashMap
的迭代顺序是和访问顺序一致的。
从上一个我们可以了解到通过 LinkedHashMap
我们可以封装一个简易版的 LRU(Least Recently Used,最近最少使用) 缓存,确保当存放的元素超过容器容量时,将最近最少访问的元素移除。
具体实现思路如下:
\nLinkedHashMap
;accessOrder
为 true ,这样在访问元素时就会把该元素移动到链表尾部,链表首元素就是最近最少被访问的元素;removeEldestEntry
方法,该方法会返回一个 boolean 值,告知 LinkedHashMap
是否需要移除链表首元素(缓存容量有限)。public class LRUCache<K, V> extends LinkedHashMap<K, V> {\n private final int capacity;\n\n public LRUCache(int capacity) {\n super(capacity, 0.75f, true);\n this.capacity = capacity;\n }\n\n /**\n * 判断size超过容量时返回true,告知LinkedHashMap移除最老的缓存项(即链表的第一个元素)\n */\n @Override\n protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {\n return size() > capacity;\n }\n}\n
测试代码如下,笔者初始化缓存容量为 2,然后按照次序先后添加 4 个元素。
\nLRUCache < Integer, String > cache = new LRUCache < > (2);\ncache.put(1, \"one\");\ncache.put(2, \"two\");\ncache.put(3, \"three\");\ncache.put(4, \"four\");\nfor (int i = 0; i < 4; i++) {\n System.out.println(cache.get(i));\n}\n
输出:
\nnull\nnull\nthree\nfour\n
从输出结果来看,由于缓存容量为 2 ,因此,添加第 3 个元素时,第 1 个元素会被删除。添加第 4 个元素时,第 2 个元素会被删除。
\n在正式讨论 LinkedHashMap
前,我们先来聊聊 LinkedHashMap
节点 Entry
的设计,我们都知道 HashMap
的 bucket 上的因为冲突转为链表的节点会在符合以下两个条件时会将链表转为红黑树:
TREEIFY_THRESHOLD - 1
。MIN_TREEIFY_CAPACITY
。\n\n🐛 修正(参见:issue#2147):
\n链表上的节点个数达到树化的阈值是 8 而非 7。因为源码的判断是从链表初始元素开始遍历,下标是从 0 开始的,所以判断条件设置为 8-1=7,其实是迭代到尾部元素时再判断整个链表长度大于等于 8 才进行树化操作。
\n\n
而 LinkedHashMap
是在 HashMap
的基础上为 bucket 上的每一个节点建立一条双向链表,这就使得转为红黑树的树节点也需要具备双向链表节点的特性,即每一个树节点都需要拥有两个引用存储前驱节点和后继节点的地址,所以对于树节点类 TreeNode
的设计就是一个比较棘手的问题。
对此我们不妨来看看两者之间节点类的类图,可以看到:
\nLinkedHashMap
的节点内部类 Entry
基于 HashMap
的基础上,增加 before
和 after
指针使节点具备双向链表的特性。HashMap
的树节点 TreeNode
继承了具备双向链表特性的 LinkedHashMap
的 Entry
。很多读者此时就会有这样一个疑问,为什么 HashMap
的树节点 TreeNode
要通过 LinkedHashMap
获取双向链表的特性呢?为什么不直接在 Node
上实现前驱和后继指针呢?
先来回答第一个问题,我们都知道 LinkedHashMap
是在 HashMap
基础上对节点增加双向指针实现双向链表的特性,所以 LinkedHashMap
内部链表转红黑树时,对应的节点会转为树节点 TreeNode
,为了保证使用 LinkedHashMap
时树节点具备双向链表的特性,所以树节点 TreeNode
需要继承 LinkedHashMap
的 Entry
。
再来说说第二个问题,我们直接在 HashMap
的节点 Node
上直接实现前驱和后继指针,然后 TreeNode
直接继承 Node
获取双向链表的特性为什么不行呢?其实这样做也是可以的。只不过这种做法会使得使用 HashMap
时存储键值对的节点类 Node
多了两个没有必要的引用,占用没必要的内存空间。
所以,为了保证 HashMap
底层的节点类 Node
没有多余的引用,又要保证 LinkedHashMap
的节点类 Entry
拥有存储链表的引用,设计者就让 LinkedHashMap
的节点 Entry
去继承 Node 并增加存储前驱后继节点的引用 before
、after
,让需要用到链表特性的节点去实现需要的逻辑。然后树节点 TreeNode
再通过继承 Entry
获取 before
、after
两个指针。
static class Entry<K,V> extends HashMap.Node<K,V> {\n Entry<K,V> before, after;\n Entry(int hash, K key, V value, Node<K,V> next) {\n super(hash, key, value, next);\n }\n }\n
但是这样做,不也使得使用 HashMap
时的 TreeNode
多了两个没有必要的引用吗?这不也是一种空间的浪费吗?
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {\n //略\n\n}\n
对于这个问题,引用作者的一段注释,作者们认为在良好的 hashCode
算法时,HashMap
转红黑树的概率不大。就算转为红黑树变为树节点,也可能会因为移除或者扩容将 TreeNode
变为 Node
,所以 TreeNode
的使用概率不算很大,对于这一点资源空间的浪费是可以接受的。
Because TreeNodes are about twice the size of regular nodes, we\nuse them only when bins contain enough nodes to warrant use\n(see TREEIFY_THRESHOLD). And when they become too small (due to\nremoval or resizing) they are converted back to plain bins. In\nusages with well-distributed user hashCodes, tree bins are\nrarely used. Ideally, under random hashCodes, the frequency of\nnodes in bins follows a Poisson distribution\n
LinkedHashMap
构造方法有 4 个实现也比较简单,直接调用父类即 HashMap
的构造方法完成初始化。
public LinkedHashMap() {\n super();\n accessOrder = false;\n}\n\npublic LinkedHashMap(int initialCapacity) {\n super(initialCapacity);\n accessOrder = false;\n}\n\npublic LinkedHashMap(int initialCapacity, float loadFactor) {\n super(initialCapacity, loadFactor);\n accessOrder = false;\n}\n\npublic LinkedHashMap(int initialCapacity,\n float loadFactor,\n boolean accessOrder) {\n super(initialCapacity, loadFactor);\n this.accessOrder = accessOrder;\n}\n
我们上面也提到了,默认情况下 accessOrder
为 false,如果我们要让 LinkedHashMap
实现键值对按照访问顺序排序(即将最近未访问的元素排在链表首部、最近访问的元素移动到链表尾部),需要调用第 4 个构造方法将 accessOrder
设置为 true。
get
方法是 LinkedHashMap
增删改查操作中唯一一个重写的方法, accessOrder
为 true 的情况下, 它会在元素查询完成之后,将当前访问的元素移到链表的末尾。
public V get(Object key) {\n Node < K, V > e;\n //获取key的键值对,若为空直接返回\n if ((e = getNode(hash(key), key)) == null)\n return null;\n //若accessOrder为true,则调用afterNodeAccess将当前元素移到链表末尾\n if (accessOrder)\n afterNodeAccess(e);\n //返回键值对的值\n return e.value;\n }\n
从源码可以看出,get
的执行步骤非常简单:
HashMap
的 getNode
获取键值对,若为空则直接返回。accessOrder
是否为 true,若为 true 则说明需要保证 LinkedHashMap
的链表访问有序性,执行步骤 3。LinkedHashMap
重写的 afterNodeAccess
将当前元素添加到链表末尾。关键点在于 afterNodeAccess
方法的实现,这个方法负责将元素移动到链表末尾。
void afterNodeAccess(Node < K, V > e) { // move node to last\n LinkedHashMap.Entry < K, V > last;\n //如果accessOrder 且当前节点不未链表尾节点\n if (accessOrder && (last = tail) != e) {\n\n //获取当前节点、以及前驱节点和后继节点\n LinkedHashMap.Entry < K, V > p =\n (LinkedHashMap.Entry < K, V > ) e, b = p.before, a = p.after;\n\n //将当前节点的后继节点指针指向空,使其和后继节点断开联系\n p.after = null;\n\n //如果前驱节点为空,则说明当前节点是链表的首节点,故将后继节点设置为首节点\n if (b == null)\n head = a;\n else\n //如果后继节点不为空,则让前驱节点指向后继节点\n b.after = a;\n\n //如果后继节点不为空,则让后继节点指向前驱节点\n if (a != null)\n a.before = b;\n else\n //如果后继节点为空,则说明当前节点在链表最末尾,直接让last 指向前驱节点,这个 else其实 没有意义,因为最开头if已经确保了p不是尾结点了,自然after不会是null\n last = b;\n\n //如果last为空,则说明当前链表只有一个节点p,则将head指向p\n if (last == null)\n head = p;\n else {\n //反之让p的前驱指针指向尾节点,再让尾节点的前驱指针指向p\n p.before = last;\n last.after = p;\n }\n //tail指向p,自此将节点p移动到链表末尾\n tail = p;\n\n ++modCount;\n }\n}\n
从源码可以看出, afterNodeAccess
方法完成了下面这些操作:
accessOrder
为 true 且链表尾部不为当前节点 p,我们则需要将当前节点移到链表尾部。可以结合这张图理解,展示了 key 为 13 的元素被移动到了链表尾部。
\n\n看不太懂也没关系,知道这个方法的作用就够了,后续有时间再慢慢消化。
\nLinkedHashMap
并没有对 remove
方法进行重写,而是直接继承 HashMap
的 remove
方法,为了保证键值对移除后双向链表中的节点也会同步被移除,LinkedHashMap
重写了 HashMap
的空实现方法 afterNodeRemoval
。
final Node<K,V> removeNode(int hash, Object key, Object value,\n boolean matchValue, boolean movable) {\n //略\n if (node != null && (!matchValue || (v = node.value) == value ||\n (value != null && value.equals(v)))) {\n if (node instanceof TreeNode)\n ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);\n else if (node == p)\n tab[index] = node.next;\n else\n p.next = node.next;\n ++modCount;\n --size;\n //HashMap的removeNode完成元素移除后会调用afterNodeRemoval进行移除后置操作\n afterNodeRemoval(node);\n return node;\n }\n }\n return null;\n }\n//空实现\nvoid afterNodeRemoval(Node<K,V> p) { }\n
我们可以看到从 HashMap
继承来的 remove
方法内部调用的 removeNode
方法将节点从 bucket 删除后,调用了 afterNodeRemoval
。
void afterNodeRemoval(Node<K,V> e) { // unlink\n\n //获取当前节点p、以及e的前驱节点b和后继节点a\n LinkedHashMap.Entry<K,V> p =\n (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;\n //将p的前驱和后继指针都设置为null,使其和前驱、后继节点断开联系\n p.before = p.after = null;\n\n //如果前驱节点为空,则说明当前节点p是链表首节点,让head指针指向后继节点a即可\n if (b == null)\n head = a;\n else\n //如果前驱节点b不为空,则让b直接指向后继节点a\n b.after = a;\n\n //如果后继节点为空,则说明当前节点p在链表末端,所以直接让tail指针指向前驱节点a即可\n if (a == null)\n tail = b;\n else\n //反之后继节点的前驱指针直接指向前驱节点\n a.before = b;\n }\n
从源码可以看出, afterNodeRemoval
方法的整体操作就是让当前节点 p 和前驱节点、后继节点断开联系,等待 gc 回收,整体步骤为:
可以结合这张图理解,展示了 key 为 13 的元素被删除,也就是从链表中移除了这个元素。
\n\n看不太懂也没关系,知道这个方法的作用就够了,后续有时间再慢慢消化。
\n同样的 LinkedHashMap
并没有实现插入方法,而是直接继承 HashMap
的所有插入方法交由用户使用,但为了维护双向链表访问的有序性,它做了这样两件事:
afterNodeAccess
(上文提到过),如果当前被插入的 key 已存在与 map
中,因为 LinkedHashMap
的插入操作会将新节点追加至链表末尾,所以对于存在的 key 则调用 afterNodeAccess
将其放到链表末端。HashMap
的 afterNodeInsertion
方法,当 removeEldestEntry
返回 true 时,会将链表首节点移除。这一点我们可以在 HashMap
的插入操作核心方法 putVal
中看到。
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,\n boolean evict) {\n //略\n if (e != null) { // existing mapping for key\n V oldValue = e.value;\n if (!onlyIfAbsent || oldValue == null)\n e.value = value;\n //如果当前的key在map中存在,则调用afterNodeAccess\n afterNodeAccess(e);\n return oldValue;\n }\n }\n ++modCount;\n if (++size > threshold)\n resize();\n //调用插入后置方法,该方法被LinkedHashMap重写\n afterNodeInsertion(evict);\n return null;\n }\n
上述步骤的源码上文已经解释过了,所以这里我们着重了解一下 afterNodeInsertion
的工作流程,假设我们的重写了 removeEldestEntry
,当链表 size
超过 capacity
时,就返回 true。
/**\n * 判断size超过容量时返回true,告知LinkedHashMap移除最老的缓存项(即链表的第一个元素)\n */\nprotected boolean removeEldestEntry(Map.Entry < K, V > eldest) {\n return size() > capacity;\n}\n
以下图为例,假设笔者最后新插入了一个不存在的节点 19,假设 capacity
为 4,所以 removeEldestEntry
返回 true,我们要将链表首节点移除。
移除的步骤很简单,查看链表首节点是否存在,若存在则断开首节点和后继节点的关系,并让首节点指针指向下一节点,所以 head 指针指向了 12,节点 10 成为没有任何引用指向的空对象,等待 GC。
\n\nvoid afterNodeInsertion(boolean evict) { // possibly remove eldest\n LinkedHashMap.Entry<K,V> first;\n //如果evict为true且队首元素不为空以及removeEldestEntry返回true,则说明我们需要最老的元素(即在链表首部的元素)移除。\n if (evict && (first = head) != null && removeEldestEntry(first)) {\n //获取链表首部的键值对的key\n K key = first.key;\n //调用removeNode将元素从HashMap的bucket中移除,并和LinkedHashMap的双向链表断开,等待gc回收\n removeNode(hash(key), key, null, false, true);\n }\n }\n
从源码可以看出, afterNodeInsertion
方法完成了下面这些操作:
eldest
是否为 true,只有为 true 才能说明可能需要将最年长的键值对(即链表首部的元素)进行移除,具体是否具体要进行移除,还得确定链表是否为空((first = head) != null)
,以及 removeEldestEntry
方法是否返回 true,只有这两个方法返回 true 才能确定当前链表不为空,且链表需要进行移除操作了。HashMap
的 removeNode
方法,该方法我们上文提到过,它会将节点从 HashMap
的 bucket 中移除,并且 LinkedHashMap
还重写了 removeNode
中的 afterNodeRemoval
方法,所以这一步将通过调用 removeNode
将元素从 HashMap
的 bucket 中移除,并和 LinkedHashMap
的双向链表断开,等待 gc 回收。LinkedHashMap
维护了一个双向链表来记录数据插入的顺序,因此在迭代遍历生成的迭代器的时候,是按照双向链表的路径进行遍历的。这一点相比于 HashMap
那种遍历整个 bucket 的方式来说,高效需多。
这一点我们可以从两者的迭代器中得以印证,先来看看 HashMap
的迭代器,可以看到 HashMap
迭代键值对时会用到一个 nextNode
方法,该方法会返回 next 指向的下一个元素,并会从 next 开始遍历 bucket 找到下一个 bucket 中不为空的元素 Node。
final class EntryIterator extends HashIterator\n implements Iterator < Map.Entry < K, V >> {\n public final Map.Entry < K,\n V > next() {\n return nextNode();\n }\n }\n\n //获取下一个Node\n final Node < K, V > nextNode() {\n Node < K, V > [] t;\n //获取下一个元素next\n Node < K, V > e = next;\n if (modCount != expectedModCount)\n throw new ConcurrentModificationException();\n if (e == null)\n throw new NoSuchElementException();\n //将next指向bucket中下一个不为空的Node\n if ((next = (current = e).next) == null && (t = table) != null) {\n do {} while (index < t.length && (next = t[index++]) == null);\n }\n return e;\n }\n
相比之下 LinkedHashMap
的迭代器则是直接使用通过 after
指针快速定位到当前节点的后继节点,简洁高效需多。
final class LinkedEntryIterator extends LinkedHashIterator\n implements Iterator < Map.Entry < K, V >> {\n public final Map.Entry < K,\n V > next() {\n return nextNode();\n }\n }\n //获取下一个Node\n final LinkedHashMap.Entry < K, V > nextNode() {\n //获取下一个节点next\n LinkedHashMap.Entry < K, V > e = next;\n if (modCount != expectedModCount)\n throw new ConcurrentModificationException();\n if (e == null)\n throw new NoSuchElementException();\n //current 指针指向当前节点\n current = e;\n //next直接当前节点的after指针快速定位到下一个节点\n next = e.after;\n return e;\n }\n
为了验证笔者所说的观点,笔者对这两个容器进行了压测,测试插入 1000w 和迭代 1000w 条数据的耗时,代码如下:
\nint count = 1000_0000;\nMap<Integer, Integer> hashMap = new HashMap<>();\nMap<Integer, Integer> linkedHashMap = new LinkedHashMap<>();\n\nlong start, end;\n\nstart = System.currentTimeMillis();\nfor (int i = 0; i < count; i++) {\n hashMap.put(ThreadLocalRandom.current().nextInt(1, count), ThreadLocalRandom.current().nextInt(0, count));\n}\nend = System.currentTimeMillis();\nSystem.out.println(\"map time putVal: \" + (end - start));\n\nstart = System.currentTimeMillis();\nfor (int i = 0; i < count; i++) {\n linkedHashMap.put(ThreadLocalRandom.current().nextInt(1, count), ThreadLocalRandom.current().nextInt(0, count));\n}\nend = System.currentTimeMillis();\nSystem.out.println(\"linkedHashMap putVal time: \" + (end - start));\n\nstart = System.currentTimeMillis();\nlong num = 0;\nfor (Integer v : hashMap.values()) {\n num = num + v;\n}\nend = System.currentTimeMillis();\nSystem.out.println(\"map get time: \" + (end - start));\n\nstart = System.currentTimeMillis();\nfor (Integer v : linkedHashMap.values()) {\n num = num + v;\n}\nend = System.currentTimeMillis();\nSystem.out.println(\"linkedHashMap get time: \" + (end - start));\nSystem.out.println(num);\n
从输出结果来看,因为 LinkedHashMap
需要维护双向链表的缘故,插入元素相较于 HashMap
会更耗时,但是有了双向链表明确的前后节点关系,迭代效率相对于前者高效了需多。不过,总体来说却别不大,毕竟数据量这么庞大。
map time putVal: 5880\nlinkedHashMap putVal time: 7567\nmap get time: 143\nlinkedHashMap get time: 67\n63208969074998\n
LinkedHashMap
是 Java 集合框架中 HashMap
的一个子类,它继承了 HashMap
的所有属性和方法,并且在 HashMap
的基础重写了 afterNodeRemoval
、afterNodeInsertion
、afterNodeAccess
方法。使之拥有顺序插入和访问有序的特性。
LinkedHashMap
按照插入顺序迭代元素是它的默认行为。LinkedHashMap
内部维护了一个双向链表,用于记录元素的插入顺序。因此,当使用迭代器迭代元素时,元素的顺序与它们最初插入的顺序相同。
LinkedHashMap
可以通过构造函数中的 accessOrder
参数指定按照访问顺序迭代元素。当 accessOrder
为 true 时,每次访问一个元素时,该元素会被移动到链表的末尾,因此下次访问该元素时,它就会成为链表中的最后一个元素,从而实现按照访问顺序迭代元素。
将 accessOrder
设置为 true 并重写 removeEldestEntry
方法当链表大小超过容量时返回 true,使得每次访问一个元素时,该元素会被移动到链表的末尾。一旦插入操作让 removeEldestEntry
返回 true 时,视为缓存已满,LinkedHashMap
就会将链表首元素移除,由此我们就能实现一个 LRU 缓存。
LinkedHashMap
和 HashMap
都是 Java 集合框架中的 Map 接口的实现类。它们的最大区别在于迭代元素的顺序。HashMap
迭代元素的顺序是不确定的,而 LinkedHashMap
提供了按照插入顺序或访问顺序迭代元素的功能。此外,LinkedHashMap
内部维护了一个双向链表,用于记录元素的插入顺序或访问顺序,而 HashMap
则没有这个链表。因此,LinkedHashMap
的插入性能可能会比 HashMap
略低,但它提供了更多的功能并且迭代效率相较于 HashMap
更加高效。
DelayQueue
是 JUC 包(java.util.concurrent)
为我们提供的延迟队列,用于实现延时任务比如订单下单 15 分钟未支付直接取消。它是 BlockingQueue
的一种,底层是一个基于 PriorityQueue
实现的一个无界队列,是线程安全的。关于PriorityQueue
可以参考笔者编写的这篇文章:PriorityQueue 源码分析 。
DelayQueue
中存放的元素必须实现 Delayed
接口,并且需要重写 getDelay()
方法(计算是否到期)。
public interface Delayed extends Comparable<Delayed> {\n long getDelay(TimeUnit unit);\n}\n
默认情况下, DelayQueue
会按照到期时间升序编排任务。只有当元素过期时(getDelay()
方法返回值小于等于 0),才能从队列中取出。
DelayQueue
最早是在 Java 5 中引入的,作为 java.util.concurrent
包中的一部分,用于支持基于时间的任务调度和缓存过期删除等场景,该版本仅仅支持延迟功能的实现,还未解决线程安全问题。DelayQueue
的实现进行了优化,通过使用 ReentrantLock
和 Condition
解决线程安全及线程间交互的效率,提高了其性能和可靠性。DelayQueue
的实现进行了进一步的优化,通过使用 CAS 操作实现元素的添加和移除操作,提高了其并发操作性能。DelayQueue
的实现没有进行重大变化,但是在 java.time
包中引入了新的时间类,如 Duration
和 Instant
,使得使用 DelayQueue
进行基于时间的调度更加方便和灵活。DelayQueue
的实现进行了一些微小的改进,主要是对代码进行了一些优化和精简。总的来说,DelayQueue
的发展史主要是通过优化其实现方式和提高其性能和可靠性,使其更加适用于基于时间的调度和缓存过期删除等场景。
我们这里希望任务可以按照我们预期的时间执行,例如提交 3 个任务,分别要求 1s、2s、3s 后执行,即使是乱序添加,1s 后要求 1s 执行的任务会准时执行。
\n\n对此我们可以使用 DelayQueue
来实现,所以我们首先需要继承 Delayed
实现 DelayedTask
,实现 getDelay
方法以及优先级比较 compareTo
。
/**\n * 延迟任务\n */\npublic class DelayedTask implements Delayed {\n /**\n * 任务到期时间\n */\n private long executeTime;\n /**\n * 任务\n */\n private Runnable task;\n\n public DelayedTask(long delay, Runnable task) {\n this.executeTime = System.currentTimeMillis() + delay;\n this.task = task;\n }\n\n /**\n * 查看当前任务还有多久到期\n * @param unit\n * @return\n */\n @Override\n public long getDelay(TimeUnit unit) {\n return unit.convert(executeTime - System.currentTimeMillis(), TimeUnit.MILLISECONDS);\n }\n\n /**\n * 延迟队列需要到期时间升序入队,所以我们需要实现compareTo进行到期时间比较\n * @param o\n * @return\n */\n @Override\n public int compareTo(Delayed o) {\n return Long.compare(this.executeTime, ((DelayedTask) o).executeTime);\n }\n\n public void execute() {\n task.run();\n }\n}\n
完成任务的封装之后,使用就很简单了,设置好多久到期然后将任务提交到延迟队列中即可。
\n// 创建延迟队列,并添加任务\nDelayQueue < DelayedTask > delayQueue = new DelayQueue < > ();\n\n//分别添加1s、2s、3s到期的任务\ndelayQueue.add(new DelayedTask(2000, () -> System.out.println(\"Task 2\")));\ndelayQueue.add(new DelayedTask(1000, () -> System.out.println(\"Task 1\")));\ndelayQueue.add(new DelayedTask(3000, () -> System.out.println(\"Task 3\")));\n\n// 取出任务并执行\nwhile (!delayQueue.isEmpty()) {\n //阻塞获取最先到期的任务\n DelayedTask task = delayQueue.take();\n if (task != null) {\n task.execute();\n }\n}\n
从输出结果可以看出,即使笔者先提到 2s 到期的任务,1s 到期的任务 Task1 还是优先执行的。
\nTask 1\nTask 2\nTask 3\n
这里以 JDK1.8 为例,分析一下 DelayQueue
的底层核心源码。
DelayQueue
的类定义如下:
public class DelayQueue<E extends Delayed> extends AbstractQueue<E> implements BlockingQueue<E>\n{\n //...\n}\n
DelayQueue
继承了 AbstractQueue
类,实现了 BlockingQueue
接口。
DelayQueue
的 4 个核心成员变量如下:
//可重入锁,实现线程安全的关键\nprivate final transient ReentrantLock lock = new ReentrantLock();\n//延迟队列底层存储数据的集合,确保元素按照到期时间升序排列\nprivate final PriorityQueue<E> q = new PriorityQueue<E>();\n\n//指向准备执行优先级最高的线程\nprivate Thread leader = null;\n//实现多线程之间等待唤醒的交互\nprivate final Condition available = lock.newCondition();\n
lock
: 我们都知道 DelayQueue
存取是线程安全的,所以为了保证存取元素时线程安全,我们就需要在存取时上锁,而 DelayQueue
就是基于 ReentrantLock
独占锁确保存取操作的线程安全。q
: 延迟队列要求元素按照到期时间进行升序排列,所以元素添加时势必需要进行优先级排序,所以 DelayQueue
底层元素的存取都是通过这个优先队列 PriorityQueue
的成员变量 q
来管理的。leader
: 延迟队列的任务只有到期之后才会执行,对于没有到期的任务只有等待,为了确保优先级最高的任务到期后可以即刻被执行,设计者就用 leader
来管理延迟任务,只有 leader
所指向的线程才具备定时等待任务到期执行的权限,而其他那些优先级低的任务只能无限期等待,直到 leader
线程执行完手头的延迟任务后唤醒它。available
: 上文讲述 leader
线程时提到的等待唤醒操作的交互就是通过 available
实现的,假如线程 1 尝试在空的 DelayQueue
获取任务时,available
就会将其放入等待队列中。直到有一个线程添加一个延迟任务后通过 available
的 signal
方法将其唤醒。相较于其他的并发容器,延迟队列的构造方法比较简单,它只有两个构造方法,因为所有成员变量在类加载时都已经初始完成了,所以默认构造方法什么也没做。还有一个传入 Collection
对象的构造方法,它会将调用 addAll()
方法将集合元素存到优先队列 q
中。
public DelayQueue() {}\n\npublic DelayQueue(Collection<? extends E> c) {\n this.addAll(c);\n}\n
DelayQueue
添加元素的方法无论是 add
、put
还是 offer
,本质上就是调用一下 offer
,所以了解延迟队列的添加逻辑我们只需阅读 offer 方法即可。
offer
方法的整体逻辑为:
lock
。q
的 offer
方法将元素存放到优先队列中。peek
方法看看当前队首元素是否就是本次入队的元素,如果是则说明当前这个元素是即将到期的任务(即优先级最高的元素),于是将 leader
设置为空,通知因为队列为空时调用 take
等方法导致阻塞的线程来争抢元素。lock
。源码如下,笔者已详细注释,读者可自行参阅:
\npublic boolean offer(E e) {\n //尝试获取lock\n final ReentrantLock lock = this.lock;\n lock.lock();\n try {\n //如果上锁成功,则调q的offer方法将元素存放到优先队列中\n q.offer(e);\n //调用peek方法看看当前队首元素是否就是本次入队的元素,如果是则说明当前这个元素是即将到期的任务(即优先级最高的元素)\n if (q.peek() == e) {\n //将leader设置为空,通知调用取元素方法而阻塞的线程来争抢这个任务\n leader = null;\n available.signal();\n }\n return true;\n } finally {\n //上述步骤执行完成,释放lock\n lock.unlock();\n }\n}\n
DelayQueue
中获取元素的方式分为阻塞式和非阻塞式,先来看看逻辑比较复杂的阻塞式获取元素方法 take
,为了让读者可以更直观的了解阻塞式获取元素的全流程,笔者将以 3 个线程并发获取元素为例讲述 take
的工作流程。
\n\n想要理解下面的内容,需要用到 AQS 相关的知识,推荐阅读下面这两篇文章:
\n\n
1、首先, 3 个线程会尝试获取可重入锁 lock
,假设我们现在有 3 个线程分别是 t1、t2、t3,随后 t1 得到了锁,而 t2、t3 没有抢到锁,故将这两个线程存入等待队列中。
2、紧接着 t1 开始进行元素获取的逻辑。
\n3、线程 t1 首先会查看 DelayQueue
队列首元素是否为空。
4、如果元素为空,则说明当前队列没有任何元素,故 t1 就会被阻塞存到 conditionWaiter
这个队列中。
注意,调用 await
之后 t1 就会释放 lcok
锁,假如 DelayQueue
持续为空,那么 t2、t3 也会像 t1 一样执行相同的逻辑并进入 conditionWaiter
队列中。
如果元素不为空,则判断当前任务是否到期,如果元素到期,则直接返回出去。如果元素未到期,则判断当前 leader
线程(DelayQueue
中唯一一个可以等待并获取元素的线程引用)是否为空,若不为空,则说明当前 leader
正在等待执行一个优先级比当前元素还高的元素到期,故当前线程 t1 只能调用 await
进入无限期等待,等到 leader
取得元素后唤醒。反之,若 leader
线程为空,则将当前线程设置为 leader 并进入有限期等待,到期后取出元素并返回。
自此我们阻塞式获取元素的逻辑都已完成后,源码如下,读者可自行参阅:
\npublic E take() throws InterruptedException {\n // 尝试获取可重入锁,将底层AQS的state设置为1,并设置为独占锁\n final ReentrantLock lock = this.lock;\n lock.lockInterruptibly();\n try {\n for (;;) {\n //查看队列第一个元素\n E first = q.peek();\n //若为空,则将当前线程放入ConditionObject的等待队列中,并将底层AQS的state设置为0,表示释放锁并进入无限期等待\n if (first == null)\n available.await();\n else {\n //若元素不为空,则查看当前元素多久到期\n long delay = first.getDelay(NANOSECONDS);\n //如果小于0则说明已到期直接返回出去\n if (delay <= 0)\n return q.poll();\n //如果大于0则说明任务还没到期,首先需要释放对这个元素的引用\n first = null; // don't retain ref while waiting\n //判断leader是否为空,如果不为空,则说明正有线程作为leader并等待一个任务到期,则当前线程进入无限期等待\n if (leader != null)\n available.await();\n else {\n //反之将我们的线程成为leader\n Thread thisThread = Thread.currentThread();\n leader = thisThread;\n try {\n //并进入有限期等待\n available.awaitNanos(delay);\n } finally {\n //等待任务到期时,释放leader引用,进入下一次循环将任务return出去\n if (leader == thisThread)\n leader = null;\n }\n }\n }\n }\n } finally {\n // 收尾逻辑:当leader为null,并且队列中有任务时,唤醒等待的获取元素的线程。\n if (leader == null && q.peek() != null)\n available.signal();\n //释放锁\n lock.unlock();\n }\n}\n
我们再来看看非阻塞的获取元素方法 poll
,逻辑比较简单,整体步骤如下:
poll
返回出去。lock
。源码如下,读者可自行参阅源码及注释:
\npublic E poll() {\n //尝试获取可重入锁\n final ReentrantLock lock = this.lock;\n lock.lock();\n try {\n //查看队列第一个元素,判断元素是否为空\n E first = q.peek();\n\n //若元素为空,或者元素未到期,则直接返回空\n if (first == null || first.getDelay(NANOSECONDS) > 0)\n return null;\n else\n //若元素不为空且到期了,直接调用poll返回出去\n return q.poll();\n } finally {\n //释放可重入锁lock\n lock.unlock();\n }\n}\n
上文获取元素时都会调用到 peek
方法,peek 顾名思义仅仅窥探一下队列中的元素,它的步骤就 4 步:
public E peek() {\n final ReentrantLock lock = this.lock;\n lock.lock();\n try {\n return q.peek();\n } finally {\n lock.unlock();\n }\n}\n
DelayQueue
底层是使用优先队列 PriorityQueue
来存储元素,而 PriorityQueue
采用二叉小顶堆的思想确保值小的元素排在最前面,这就使得 DelayQueue
对于延迟任务优先级的管理就变得十分方便了。同时 DelayQueue
为了保证线程安全还用到了可重入锁 ReentrantLock
,确保单位时间内只有一个线程可以操作延迟队列。最后,为了实现多线程之间等待和唤醒的交互效率,DelayQueue
还用到了 Condition
,通过 Condition
的 await
和 signal
方法完成多线程之间的等待唤醒。
DelayQueue
的实现是线程安全的,它通过 ReentrantLock
实现了互斥访问和 Condition
实现了线程间的等待和唤醒操作,可以保证多线程环境下的安全性和可靠性。
DelayQueue
通常用于实现定时任务调度和缓存过期删除等场景。在定时任务调度中,需要将需要执行的任务封装成延迟任务对象,并将其添加到 DelayQueue
中,DelayQueue
会自动按照剩余延迟时间进行升序排序(默认情况),以保证任务能够按照时间先后顺序执行。对于缓存过期这个场景而言,在数据被缓存到内存之后,我们可以将缓存的 key 封装成一个延迟的删除任务,并将其添加到 DelayQueue
中,当数据过期时,拿到这个任务的 key,将这个 key 从内存中移除。
Delayed
接口定义了元素的剩余延迟时间(getDelay
)和元素之间的比较规则(该接口继承了 Comparable
接口)。若希望元素能够存放到 DelayQueue
中,就必须实现 Delayed
接口的 getDelay()
方法和 compareTo()
方法,否则 DelayQueue
无法得知当前任务剩余时长和任务优先级的比较。
DelayQueue
和 Timer/TimerTask
都可以用于实现定时任务调度,但是它们的实现方式不同。DelayQueue
是基于优先级队列和堆排序算法实现的,可以实现多个任务按照时间先后顺序执行;而 Timer/TimerTask
是基于单线程实现的,只能按照任务的执行顺序依次执行,如果某个任务执行时间过长,会影响其他任务的执行。另外,DelayQueue
还支持动态添加和移除任务,而 Timer/TimerTask
只能在创建时指定任务。
PriorityQueue 源码分析 为我的知识星球(点击链接即可查看详细介绍以及加入方法)专属内容,已经整理到了《Java 必读源码系列》中。
\n\n《Java 必读源码系列》(点击链接即可查看详细介绍)的部分内容展示如下。
\n\n为了帮助更多同学准备 Java 面试以及学习 Java ,我创建了一个纯粹的Java 面试知识星球。虽然收费只有培训班/训练营的百分之一,但是知识星球里的内容质量更高,提供的服务也更全面,非常适合准备 Java 面试和学习 Java 的同学。
\n欢迎准备 Java 面试以及学习 Java 的同学加入我的 知识星球,干货非常多,学习氛围也很不错!收费虽然是白菜价,但星球里的内容或许比你参加上万的培训班质量还要高。
\n下面是星球提供的部分服务(点击下方图片即可获取知识星球的详细介绍):
\n\n我有自己的原则,不割韭菜,用心做内容,真心希望帮助到你!
\n如果你感兴趣的话,不妨花 3 分钟左右看看星球的详细介绍:JavaGuide 知识星球详细介绍 。
\n这里再送一个 30 元的星球专属优惠券,数量有限(价格即将上调。老用户续费半价 ,微信扫码即可续费)!
\n\n进入星球之后,记得查看 星球使用指南 (一定要看!!!) 和 星球优质主题汇总 。
\n无任何套路,无任何潜在收费项。用心做内容,不割韭菜!
\n不过, 一定要确定需要再进 。并且, 三天之内觉得内容不满意可以全额退款 。
\n\n", "image": "https://oss.javaguide.cn/xingqiu/image-20230727084055593.png", "date_published": "2023-06-30T11:46:59.000Z", "date_modified": "2023-10-26T22:44:02.000Z", "authors": [], "tags": [ "Java" ] }, { "title": "常见加密算法总结", "url": "https://javaguide.cn/system-design/security/encryption-algorithms.html", "id": "https://javaguide.cn/system-design/security/encryption-algorithms.html", "summary": "加密算法是一种用数学方法对数据进行变换的技术,目的是保护数据的安全,防止被未经授权的人读取或修改。加密算法可以分为三大类:对称加密算法、非对称加密算法和哈希算法(也叫摘要算法)。 日常开发中常见的需要用到的加密算法的场景: 保存在数据库中的密码需要加盐之后使用哈希算法(比如 BCrypt)进行加密。 保存在数据库中的银行卡号、身份号这类敏感数据需要使用...", "content_html": "加密算法是一种用数学方法对数据进行变换的技术,目的是保护数据的安全,防止被未经授权的人读取或修改。加密算法可以分为三大类:对称加密算法、非对称加密算法和哈希算法(也叫摘要算法)。
\n日常开发中常见的需要用到的加密算法的场景:
\n哈希算法也叫哈希算法、散列函数或摘要算法,它的作用是对任意长度的数据生成一个固定长度的唯一标识,也叫哈希值、散列值或消息摘要(后文统称为哈希值)。
\n\n哈希值的作用是可以用来验证数据的完整性和一致性。
\n举两个实际的例子:
\n这种算法的特点是不可逆:
\n哈希算法分为两类:
\n常见的哈希算法有:
\n哈希算法一般是不需要密钥的,但也存在部分特殊哈希算法需要密钥。例如,MAC 和 SipHash 就是一种基于密钥的哈希算法,它在哈希算法的基础上增加了一个密钥,使得只有知道密钥的人才能验证数据的完整性和来源。
\nMD 算法有多个版本,包括 MD2、MD4、MD5 等,其中 MD5 是最常用的版本,它可以生成一个 128 位(16 字节)的哈希值。从安全性上说:MD5 > MD4 > MD2。除了这些版本,还有一些基于 MD4 或 MD5 改进的算法,如 RIPEMD、HAVAL 等。
\n即使是最安全 MD 算法 MD5 也存在被破解的风险,攻击者可以通过暴力破解或彩虹表攻击等方式,找到与原始数据相同的哈希值,从而破解数据。
\n为了增加破解难度,通常可以选择加盐。盐(Salt)在密码学中,是指通过在密码任意固定位置插入特定的字符串,让哈希后的结果和使用原始密码的哈希结果不相符,这种过程称之为“加盐”。
\n加盐之后就安全了吗?并不一定,这只是增加了破解难度,不代表无法破解。而且,MD5 算法本身就存在弱碰撞(Collision)问题,即多个不同的输入产生相同的 MD5 值。
\n因此,MD 算法已经不被推荐使用,建议使用更安全的哈希算法比如 SHA-2、Bcrypt。
\nJava 提供了对 MD 算法系列的支持,包括 MD2、MD5。
\nMD5 代码示例(未加盐):
\nString originalString = \"Java学习 + 面试指南:javaguide.cn\";\n// 创建MD5摘要对象\nMessageDigest messageDigest = MessageDigest.getInstance(\"MD5\");\nmessageDigest.update(originalString.getBytes(StandardCharsets.UTF_8));\n// 计算哈希值\nbyte[] result = messageDigest.digest();\n// 将哈希值转换为十六进制字符串\nString hexString = new HexBinaryAdapter().marshal(result);\nSystem.out.println(\"Original String: \" + originalString);\nSystem.out.println(\"MD5 Hash: \" + hexString.toLowerCase());\n
输出:
\nOriginal String: Java学习 + 面试指南:javaguide.cn\nMD5 Hash: fb246796f5b1b60d4d0268c817c608fa\n
SHA(Secure Hash Algorithm)系列算法是一组密码哈希算法,用于将任意长度的数据映射为固定长度的哈希值。SHA 系列算法由美国国家安全局(NSA)于 1993 年设计,目前共有 SHA-1、SHA-2、SHA-3 三种版本。
\nSHA-1 算法将任意长度的数据映射为 160 位的哈希值。然而,SHA-1 算法存在一些严重的缺陷,比如安全性低,容易受到碰撞攻击和长度扩展攻击。因此,SHA-1 算法已经不再被推荐使用。 SHA-2 家族(如 SHA-256、SHA-384、SHA-512 等)和 SHA-3 系列是 SHA-1 算法的替代方案,它们都提供了更高的安全性和更长的哈希值长度。
\nSHA-2 家族是在 SHA-1 算法的基础上改进而来的,它们采用了更复杂的运算过程和更多的轮次,使得攻击者更难以通过预计算或巧合找到碰撞。
\n为了寻找一种更安全和更先进的密码哈希算法,美国国家标准与技术研究院(National Institute of Standards and Technology,简称 NIST)在 2007 年公开征集 SHA-3 的候选算法。NIST 一共收到了 64 个算法方案,经过多轮的评估和筛选,最终在 2012 年宣布 Keccak 算法胜出,成为 SHA-3 的标准算法(SHA-3 与 SHA-2 算法没有直接的关系)。 Keccak 算法具有与 MD 和 SHA-1/2 完全不同的设计思路,即海绵结构(Sponge Construction),使得传统攻击方法无法直接应用于 SHA-3 的攻击中(能够抵抗目前已知的所有攻击方式包括碰撞攻击、长度扩展攻击、差分攻击等)。
\n由于 SHA-2 算法还没有出现重大的安全漏洞,而且在软件中的效率更高,所以大多数人还是倾向于使用 SHA-2 算法。
\n相比 MD5 算法,SHA-2 算法之所以更强,主要有两个原因:
\n当然,SHA-2 也不是绝对安全的,也有被暴力破解或者彩虹表攻击的风险,所以,在实际的应用中,加盐还是必不可少的。
\nJava 提供了对 SHA 算法系列的支持,包括 SHA-1、SHA-256、SHA-384 和 SHA-512。
\nSHA-256 代码示例(未加盐):
\nString originalString = \"Java学习 + 面试指南:javaguide.cn\";\n// 创建SHA-256摘要对象\nMessageDigest messageDigest = MessageDigest.getInstance(\"SHA-256\");\nmessageDigest.update(originalString.getBytes());\n// 计算哈希值\nbyte[] result = messageDigest.digest();\n// 将哈希值转换为十六进制字符串\nString hexString = new HexBinaryAdapter().marshal(result);\nSystem.out.println(\"Original String: \" + originalString);\nSystem.out.println(\"SHA-256 Hash: \" + hexString.toLowerCase());\n
输出:
\nOriginal String: Java学习 + 面试指南:javaguide.cn\nSHA-256 Hash: 184eb7e1d7fb002444098c9bde3403c6f6722c93ecfac242c0e35cd9ed3b41cd\n
Bcrypt 算法是一种基于 Blowfish 加密算法的密码哈希算法,专门为密码加密而设计,安全性高。
\n由于 Bcrypt 采用了 salt(盐) 和 cost(成本) 两种机制,它可以有效地防止彩虹表攻击和暴力破解攻击,从而保证密码的安全性。salt 是一个随机生成的字符串,用于和密码混合,增加密码的复杂度和唯一性。cost 是一个数值参数,用于控制 Bcrypt 算法的迭代次数,增加密码哈希的计算时间和资源消耗。
\nBcrypt 算法可以根据实际情况进行调整加密的复杂度,可以设置不同的 cost 值和 salt 值,从而满足不同的安全需求,灵活性很高。
\nJava 应用程序的安全框架 Spring Security 支持多种密码编码器,其中 BCryptPasswordEncoder
是官方推荐的一种,它使用 BCrypt 算法对用户的密码进行加密存储。
@Bean\npublic PasswordEncoder passwordEncoder(){\n return new BCryptPasswordEncoder();\n}\n
对称加密算法是指加密和解密使用同一个密钥的算法,也叫共享密钥加密算法。
\n\n常见的对称加密算法有 DES、3DES、AES 等。
\nDES(Data Encryption Standard)使用 64 位的密钥(有效秘钥长度为 56 位,8 位奇偶校验位)和 64 位的明文进行加密。
\n虽然 DES 一次只能加密 64 位,但我们只需要把明文划分成 64 位一组的块,就可以实现任意长度明文的加密。如果明文长度不是 64 位的倍数,必须进行填充,常用的模式有 PKCS5Padding, PKCS7Padding, NOPADDING。
\nDES 加密算法的基本思想是将 64 位的明文分成两半,然后对每一半进行多轮的变换,最后再合并成 64 位的密文。这些变换包括置换、异或、选择、移位等操作,每一轮都使用了一个子密钥,而这些子密钥都是由同一个 56 位的主密钥生成的。DES 加密算法总共进行了 16 轮变换,最后再进行一次逆置换,得到最终的密文。
\n\n这是一个经典的对称加密算法,但也有明显的缺陷,即 56 位的密钥安全性不足,已被证实可以在短时间内破解。
\n为了提高 DES 算法的安全性,人们提出了一些变种或者替代方案,例如 3DES(Triple DES)。
\n3DES(Triple DES)是 DES 向 AES 过渡的加密算法,它使用 2 个或者 3 个 56 位的密钥对数据进行三次加密。3DES 相当于是对每个数据块应用三次 DES 的对称加密算法。
\n为了兼容普通的 DES,3DES 并没有直接使用 加密->加密->加密 的方式,而是采用了加密->解密->加密 的方式。当三种密钥均相同时,前两步相互抵消,相当于仅实现了一次加密,因此可实现对普通 DES 加密算法的兼容。3DES 比 DES 更为安全,但其处理速度不高。
\nAES(Advanced Encryption Standard)算法是一种更先进的对称密钥加密算法,它使用 128 位、192 位或 256 位的密钥对数据进行加密或解密,密钥越长,安全性越高。
\nAES 也是一种分组(或者叫块)密码,分组长度只能是 128 位,也就是说,每个分组为 16 个字节。AES 加密算法有多种工作模式(mode of operation),如:ECB、CBC、OFB、CFB、CTR、XTS、OCB、GCM(目前使用最广泛的模式)。不同的模式参数和加密流程不同,但是核心仍然是 AES 算法。
\n和 DES 类似,对于不是 128 位倍数的明文需要进行填充,常用的填充模式有 PKCS5Padding, PKCS7Padding, NOPADDING。不过,AES-GCM 是流加密算法,可以对任意长度的明文进行加密,所以对应的填充模式为 NoPadding,即无需填充。
\nAES 的速度比 3DES 快,而且更安全。
\n\nDES 算法和 AES 算法简单对比(图片来自于:RSA vs. AES Encryption: Key Differences Explained):
\n\n基于 Java 实现 AES 算法代码示例:
\nprivate static final String AES_ALGORITHM = \"AES\";\n// AES密钥\nprivate static final String AES_SECRET_KEY = \"4128D9CDAC7E2F82951CBAF7FDFE675B\";\n// AES加密模式为GCM,填充方式为NoPadding\n// AES-GCM 是流加密(Stream cipher)算法,所以对应的填充模式为 NoPadding,即无需填充。\nprivate static final String AES_TRANSFORMATION = \"AES/GCM/NoPadding\";\n// 加密器\nprivate static Cipher encryptionCipher;\n// 解密器\nprivate static Cipher decryptionCipher;\n\n/**\n * 完成一些初始化工作\n */\npublic static void init() throws Exception {\n // 将AES密钥转换为SecretKeySpec对象\n SecretKeySpec secretKeySpec = new SecretKeySpec(AES_SECRET_KEY.getBytes(), AES_ALGORITHM);\n // 使用指定的AES加密模式和填充方式获取对应的加密器并初始化\n encryptionCipher = Cipher.getInstance(AES_TRANSFORMATION);\n encryptionCipher.init(Cipher.ENCRYPT_MODE, secretKeySpec);\n // 使用指定的AES加密模式和填充方式获取对应的解密器并初始化\n decryptionCipher = Cipher.getInstance(AES_TRANSFORMATION);\n decryptionCipher.init(Cipher.DECRYPT_MODE, secretKeySpec, new GCMParameterSpec(128, encryptionCipher.getIV()));\n}\n\n/**\n * 加密\n */\npublic static String encrypt(String data) throws Exception {\n byte[] dataInBytes = data.getBytes();\n // 加密数据\n byte[] encryptedBytes = encryptionCipher.doFinal(dataInBytes);\n return Base64.getEncoder().encodeToString(encryptedBytes);\n}\n\n/**\n * 解密\n */\npublic static String decrypt(String encryptedData) throws Exception {\n byte[] dataInBytes = Base64.getDecoder().decode(encryptedData);\n // 解密数据\n byte[] decryptedBytes = decryptionCipher.doFinal(dataInBytes);\n return new String(decryptedBytes, StandardCharsets.UTF_8);\n}\n\npublic static void main(String[] args) throws Exception {\n String originalString = \"Java学习 + 面试指南:javaguide.cn\";\n init();\n String encryptedData = encrypt(originalString);\n String decryptedData = decrypt(encryptedData);\n System.out.println(\"Original String: \" + originalString);\n System.out.println(\"AES Encrypted Data : \" + encryptedData);\n System.out.println(\"AES Decrypted Data : \" + decryptedData);\n}\n
输出:
\nOriginal String: Java学习 + 面试指南:javaguide.cn\nAES Encrypted Data : E1qTkK91suBqToag7WCyoFP9uK5hR1nSfM6p+oBlYj71bFiIVnk5TsQRT+zpjv8stha7oyKi3jQ=\nAES Decrypted Data : Java学习 + 面试指南:javaguide.cn\n
非对称加密算法是指加密和解密使用不同的密钥的算法,也叫公开密钥加密算法。这两个密钥互不相同,一个称为公钥,另一个称为私钥。公钥可以公开给任何人使用,私钥则要保密。
\n如果用公钥加密数据,只能用对应的私钥解密(加密);如果用私钥加密数据,只能用对应的公钥解密(签名)。这样就可以实现数据的安全传输和身份认证。
\n\n常见的非对称加密算法有 RSA、DSA、ECC 等。
\nRSA(Rivest–Shamir–Adleman algorithm)算法是一种基于大数分解的困难性的非对称加密算法,它需要选择两个大素数作为私钥的一部分,然后计算出它们的乘积作为公钥的一部分(寻求两个大素数比较简单,而将它们的乘积进行因式分解却极其困难)。RSA 算法原理的详细介绍,可以参考这篇文章:你真的了解 RSA 加密算法吗? - 小傅哥。
\nRSA 算法的安全性依赖于大数分解的难度,目前已经有 512 位和 768 位的 RSA 公钥被成功分解,因此建议使用 2048 位或以上的密钥长度。
\nRSA 算法的优点是简单易用,可以用于数据加密和数字签名;缺点是运算速度慢,不适合大量数据的加密。
\nRSA 算法是是目前应用最广泛的非对称加密算法,像 SSL/TLS、SSH 等协议中就用到了 RSA 算法。
\n\n基于 Java 实现 RSA 算法代码示例:
\nprivate static final String RSA_ALGORITHM = \"RSA\";\n\n/**\n * 生成RSA密钥对\n */\npublic static KeyPair generateKeyPair() throws NoSuchAlgorithmException {\n KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance(RSA_ALGORITHM);\n // 密钥大小为2048位\n keyPairGenerator.initialize(2048);\n return keyPairGenerator.generateKeyPair();\n}\n\n/**\n * 使用公钥加密数据\n */\npublic static String encrypt(String data, PublicKey publicKey) throws Exception {\n Cipher cipher = Cipher.getInstance(RSA_ALGORITHM);\n cipher.init(Cipher.ENCRYPT_MODE, publicKey);\n byte[] encryptedData = cipher.doFinal(data.getBytes(StandardCharsets.UTF_8));\n return Base64.getEncoder().encodeToString(encryptedData);\n}\n\n/**\n * 使用私钥解密数据\n */\npublic static String decrypt(String encryptedData, PrivateKey privateKey) throws Exception {\n byte[] decodedData = Base64.getDecoder().decode(encryptedData);\n Cipher cipher = Cipher.getInstance(RSA_ALGORITHM);\n cipher.init(Cipher.DECRYPT_MODE, privateKey);\n byte[] decryptedData = cipher.doFinal(decodedData);\n return new String(decryptedData, StandardCharsets.UTF_8);\n}\n\npublic static void main(String[] args) throws Exception {\n KeyPair keyPair = generateKeyPair();\n PublicKey publicKey = keyPair.getPublic();\n PrivateKey privateKey = keyPair.getPrivate();\n String originalString = \"Java学习 + 面试指南:javaguide.cn\";\n String encryptedData = encrypt(originalString, publicKey);\n String decryptedData = decrypt(encryptedData, privateKey);\n System.out.println(\"Original String: \" + originalString);\n System.out.println(\"RSA Encrypted Data : \" + encryptedData);\n System.out.println(\"RSA Decrypted Data : \" + decryptedData);\n}\n
输出:
\nOriginal String: Java学习 + 面试指南:javaguide.cn\nRSA Encrypted Data : T9ey/CEPUAhZm4UJjuVNIg8RPd1fQ32S9w6+rvOKxmuMumkJY2daFfWuCn8A73Mk5bL6TigOJI0GHfKOt/W2x968qLM3pBGCcPX17n4pR43f32IIIz9iPdgF/INOqDxP5ZAtCDvTiuzcSgDHXqiBSK5TDjtj7xoGjfudYAXICa8pWitnqDgJYoo2J0F8mKzxoi8D8eLE455MEx8ZT1s7FUD/z7/H8CfShLRbO9zq/zFI06TXn123ufg+F4lDaq/5jaIxGVEUB/NFeX4N6OZCFHtAV32mw71BYUadzI9TgvkkUr1rSKmQ0icNhnRdKedJokGUh8g9QQ768KERu92Ibg==\nRSA Decrypted Data : Java学习 + 面试指南:javaguide.cn\n
DSA(Digital Signature Algorithm)算法是一种基于离散对数的困难性的非对称加密算法,它需要选择一个素数 q 和一个 q 的倍数 p 作为私钥的一部分,然后计算出一个模 p 的原根 g 和一个模 q 的整数 y 作为公钥的一部分。DSA 算法的安全性依赖于离散对数的难度,目前已经有 1024 位的 DSA 公钥被成功破解,因此建议使用 2048 位或以上的密钥长度。
\nDSA 算法的优点是数字签名速度快,适合生成数字证书;缺点是不能用于数据加密,且签名过程需要随机数。
\nDSA 算法签名过程:
\n这篇文章介绍了三种加密算法:哈希算法、对称加密算法和非对称加密算法。
\n在学习 NIO 之前,需要先了解一下计算机 I/O 模型的基础理论知识。还不了解的话,可以参考我写的这篇文章:Java IO 模型详解。
\n在传统的 Java I/O 模型(BIO)中,I/O 操作是以阻塞的方式进行的。也就是说,当一个线程执行一个 I/O 操作时,它会被阻塞直到操作完成。这种阻塞模型在处理多个并发连接时可能会导致性能瓶颈,因为需要为每个连接创建一个线程,而线程的创建和切换都是有开销的。
\n为了解决这个问题,在 Java1.4 版本引入了一种新的 I/O 模型 — NIO (New IO,也称为 Non-blocking IO) 。NIO 弥补了同步阻塞 I/O 的不足,它在标准 Java 代码中提供了非阻塞、面向缓冲、基于通道的 I/O,可以使用少量的线程来处理多个连接,大大提高了 I/O 效率和并发。
\n下图是 BIO、NIO 和 AIO 处理客户端请求的简单对比图(关于 AIO 的介绍,可以看我写的这篇文章:Java IO 模型详解,不是重点,了解即可)。
\n\n⚠️需要注意:使用 NIO 并不一定意味着高性能,它的性能优势主要体现在高并发和高延迟的网络环境下。当连接数较少、并发程度较低或者网络传输速度较快时,NIO 的性能并不一定优于传统的 BIO 。
\nNIO 主要包括以下三个核心组件:
\n三者的关系如下图所示(暂时不理解没关系,后文会详细介绍):
\n\n下面详细介绍一下这三个组件。
\n在传统的 BIO 中,数据的读写是面向流的, 分为字节流和字符流。
\n在 Java 1.4 的 NIO 库中,所有数据都是用缓冲区处理的,这是新库和之前的 BIO 的一个重要区别,有点类似于 BIO 中的缓冲流。NIO 在读取数据时,它是直接读到缓冲区中的。在写入数据时,写入到缓冲区中。 使用 NIO 在读写数据时,都是通过缓冲区进行操作。
\nBuffer
的子类如下图所示。其中,最常用的是 ByteBuffer
,它可以用来存储和操作字节数据。
你可以将 Buffer 理解为一个数组,IntBuffer
、FloatBuffer
、CharBuffer
等分别对应 int[]
、float[]
、char[]
等。
为了更清晰地认识缓冲区,我们来简单看看Buffer
类中定义的四个成员变量:
public abstract class Buffer {\n // Invariants: mark <= position <= limit <= capacity\n private int mark = -1;\n private int position = 0;\n private int limit;\n private int capacity;\n}\n
这四个成员变量的具体含义如下:
\ncapacity
):Buffer
可以存储的最大数据量,Buffer
创建时设置且不可改变;limit
):Buffer
中可以读/写数据的边界。写模式下,limit
代表最多能写入的数据,一般等于 capacity
(可以通过limit(int newLimit)
方法设置);读模式下,limit
等于 Buffer 中实际写入的数据大小。position
):下一个可以被读写的数据的位置(索引)。从写操作模式到读操作模式切换的时候(flip),position
都会归零,这样就可以从头开始读写了。mark
):Buffer
允许将位置直接定位到该标记处,这是一个可选属性;并且,上述变量满足如下的关系:0 <= mark <= position <= limit <= capacity 。
\n另外,Buffer 有读模式和写模式这两种模式,分别用于从 Buffer 中读取数据或者向 Buffer 中写入数据。Buffer 被创建之后默认是写模式,调用 flip()
可以切换到读模式。如果要再次切换回写模式,可以调用 clear()
或者 compact()
方法。
Buffer
对象不能通过 new
调用构造方法创建对象 ,只能通过静态方法实例化 Buffer
。
这里以 ByteBuffer
为例进行介绍:
// 分配堆内存\npublic static ByteBuffer allocate(int capacity);\n// 分配直接内存\npublic static ByteBuffer allocateDirect(int capacity);\n
Buffer 最核心的两个方法:
\nget
: 读取缓冲区的数据put
:向缓冲区写入数据除上述两个方法之外,其他的重要方法:
\nflip
:将缓冲区从写模式切换到读模式,它会将 limit
的值设置为当前 position
的值,将 position
的值设置为 0。clear
: 清空缓冲区,将缓冲区从读模式切换到写模式,并将 position
的值设置为 0,将 limit
的值设置为 capacity
的值。Buffer 中数据变化的过程:
\nimport java.nio.*;\n\npublic class CharBufferDemo {\n public static void main(String[] args) {\n // 分配一个容量为8的CharBuffer\n CharBuffer buffer = CharBuffer.allocate(8);\n System.out.println(\"初始状态:\");\n printState(buffer);\n\n // 向buffer写入3个字符\n buffer.put('a').put('b').put('c');\n System.out.println(\"写入3个字符后的状态:\");\n printState(buffer);\n\n // 调用flip()方法,准备读取buffer中的数据,将 position 置 0,limit 的置 3\n buffer.flip();\n System.out.println(\"调用flip()方法后的状态:\");\n printState(buffer);\n\n // 读取字符\n while (buffer.hasRemaining()) {\n System.out.print(buffer.get());\n }\n\n // 调用clear()方法,清空缓冲区,将 position 的值置为 0,将 limit 的值置为 capacity 的值\n buffer.clear();\n System.out.println(\"调用clear()方法后的状态:\");\n printState(buffer);\n\n }\n\n // 打印buffer的capacity、limit、position、mark的位置\n private static void printState(CharBuffer buffer) {\n System.out.print(\"capacity: \" + buffer.capacity());\n System.out.print(\", limit: \" + buffer.limit());\n System.out.print(\", position: \" + buffer.position());\n System.out.print(\", mark 开始读取的字符: \" + buffer.mark());\n System.out.println(\"\\n\");\n }\n}\n
输出:
\n初始状态:\ncapacity: 8, limit: 8, position: 0\n\n写入3个字符后的状态:\ncapacity: 8, limit: 8, position: 3\n\n准备读取buffer中的数据!\n\n调用flip()方法后的状态:\ncapacity: 8, limit: 3, position: 0\n\n读取到的数据:abc\n\n调用clear()方法后的状态:\ncapacity: 8, limit: 8, position: 0\n
为了帮助理解,我绘制了一张图片展示 capacity
、limit
和position
每一阶段的变化。
Channel 是一个通道,它建立了与数据源(如文件、网络套接字等)之间的连接。我们可以利用它来读取和写入数据,就像打开了一条自来水管,让数据在 Channel 中自由流动。
\nBIO 中的流是单向的,分为各种 InputStream
(输入流)和 OutputStream
(输出流),数据只是在一个方向上传输。通道与流的不同之处在于通道是双向的,它可以用于读、写或者同时用于读写。
Channel 与前面介绍的 Buffer 打交道,读操作的时候将 Channel 中的数据填充到 Buffer 中,而写操作时将 Buffer 中的数据写入到 Channel 中。
\n\n另外,因为 Channel 是全双工的,所以它可以比流更好地映射底层操作系统的 API。特别是在 UNIX 网络编程模型中,底层操作系统的通道都是全双工的,同时支持读写操作。
\nChannel
的子类如下图所示。
其中,最常用的是以下几种类型的通道:
\nFileChannel
:文件访问通道;SocketChannel
、ServerSocketChannel
:TCP 通信通道;DatagramChannel
:UDP 通信通道;Channel 最核心的两个方法:
\nread
:读取数据并写入到 Buffer 中。write
:将 Buffer 中的数据写入到 Channel 中。这里我们以 FileChannel
为例演示一下是读取文件数据的。
RandomAccessFile reader = new RandomAccessFile(\"/Users/guide/Documents/test_read.in\", \"r\"))\nFileChannel channel = reader.getChannel();\nByteBuffer buffer = ByteBuffer.allocate(1024);\nchannel.read(buffer);\n
Selector(选择器) 是 NIO 中的一个关键组件,它允许一个线程处理多个 Channel。Selector 是基于事件驱动的 I/O 多路复用模型,主要运作原理是:通过 Selector 注册通道的事件,Selector 会不断地轮询注册在其上的 Channel。当事件发生时,比如:某个 Channel 上面有新的 TCP 连接接入、读和写事件,这个 Channel 就处于就绪状态,会被 Selector 轮询出来。Selector 会将相关的 Channel 加入到就绪集合中。通过 SelectionKey 可以获取就绪 Channel 的集合,然后对这些就绪的 Channel 进行响应的 I/O 操作。
\n\n一个多路复用器 Selector 可以同时轮询多个 Channel,由于 JDK 使用了 epoll()
代替传统的 select
实现,所以它并没有最大连接句柄 1024/2048
的限制。这也就意味着只需要一个线程负责 Selector 的轮询,就可以接入成千上万的客户端。
Selector 可以监听以下四种事件类型:
\nSelectionKey.OP_ACCEPT
:表示通道接受连接的事件,这通常用于 ServerSocketChannel
。SelectionKey.OP_CONNECT
:表示通道完成连接的事件,这通常用于 SocketChannel
。SelectionKey.OP_READ
:表示通道准备好进行读取的事件,即有数据可读。SelectionKey.OP_WRITE
:表示通道准备好进行写入的事件,即可以写入数据。Selector
是抽象类,可以通过调用此类的 open()
静态方法来创建 Selector 实例。Selector 可以同时监控多个 SelectableChannel
的 IO
状况,是非阻塞 IO
的核心。
一个 Selector 实例有三个 SelectionKey
集合:
SelectionKey
集合:代表了注册在该 Selector 上的 Channel
,这个集合可以通过 keys()
方法返回。SelectionKey
集合:代表了所有可通过 select()
方法获取的、需要进行 IO
处理的 Channel,这个集合可以通过 selectedKeys()
返回。SelectionKey
集合:代表了所有被取消注册关系的 Channel
,在下一次执行 select()
方法时,这些 Channel
对应的 SelectionKey
会被彻底删除,程序通常无须直接访问该集合,也没有暴露访问的方法。简单演示一下如何遍历被选择的 SelectionKey
集合并进行处理:
Set<SelectionKey> selectedKeys = selector.selectedKeys();\nIterator<SelectionKey> keyIterator = selectedKeys.iterator();\nwhile (keyIterator.hasNext()) {\n SelectionKey key = keyIterator.next();\n if (key != null) {\n if (key.isAcceptable()) {\n // ServerSocketChannel 接收了一个新连接\n } else if (key.isConnectable()) {\n // 表示一个新连接建立\n } else if (key.isReadable()) {\n // Channel 有准备好的数据,可以读取\n } else if (key.isWritable()) {\n // Channel 有空闲的 Buffer,可以写入数据\n }\n }\n keyIterator.remove();\n}\n
Selector 还提供了一系列和 select()
相关的方法:
int select()
:监控所有注册的 Channel
,当它们中间有需要处理的 IO
操作时,该方法返回,并将对应的 SelectionKey
加入被选择的 SelectionKey
集合中,该方法返回这些 Channel
的数量。int select(long timeout)
:可以设置超时时长的 select()
操作。int selectNow()
:执行一个立即返回的 select()
操作,相对于无参数的 select()
方法而言,该方法不会阻塞线程。Selector wakeup()
:使一个还未返回的 select()
方法立刻返回。使用 Selector 实现网络读写的简单示例:
\nimport java.io.IOException;\nimport java.net.InetSocketAddress;\nimport java.nio.ByteBuffer;\nimport java.nio.channels.SelectionKey;\nimport java.nio.channels.Selector;\nimport java.nio.channels.ServerSocketChannel;\nimport java.nio.channels.SocketChannel;\nimport java.util.Iterator;\nimport java.util.Set;\n\npublic class NioSelectorExample {\n\n public static void main(String[] args) {\n try {\n ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();\n serverSocketChannel.configureBlocking(false);\n serverSocketChannel.socket().bind(new InetSocketAddress(8080));\n\n Selector selector = Selector.open();\n // 将 ServerSocketChannel 注册到 Selector 并监听 OP_ACCEPT 事件\n serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);\n\n while (true) {\n int readyChannels = selector.select();\n\n if (readyChannels == 0) {\n continue;\n }\n\n Set<SelectionKey> selectedKeys = selector.selectedKeys();\n Iterator<SelectionKey> keyIterator = selectedKeys.iterator();\n\n while (keyIterator.hasNext()) {\n SelectionKey key = keyIterator.next();\n\n if (key.isAcceptable()) {\n // 处理连接事件\n ServerSocketChannel server = (ServerSocketChannel) key.channel();\n SocketChannel client = server.accept();\n client.configureBlocking(false);\n\n // 将客户端通道注册到 Selector 并监听 OP_READ 事件\n client.register(selector, SelectionKey.OP_READ);\n } else if (key.isReadable()) {\n // 处理读事件\n SocketChannel client = (SocketChannel) key.channel();\n ByteBuffer buffer = ByteBuffer.allocate(1024);\n int bytesRead = client.read(buffer);\n\n if (bytesRead > 0) {\n buffer.flip();\n System.out.println(\"收到数据:\" +new String(buffer.array(), 0, bytesRead));\n // 将客户端通道注册到 Selector 并监听 OP_WRITE 事件\n client.register(selector, SelectionKey.OP_WRITE);\n } else if (bytesRead < 0) {\n // 客户端断开连接\n client.close();\n }\n } else if (key.isWritable()) {\n // 处理写事件\n SocketChannel client = (SocketChannel) key.channel();\n ByteBuffer buffer = ByteBuffer.wrap(\"Hello, Client!\".getBytes());\n client.write(buffer);\n\n // 将客户端通道注册到 Selector 并监听 OP_READ 事件\n client.register(selector, SelectionKey.OP_READ);\n }\n\n keyIterator.remove();\n }\n }\n } catch (IOException e) {\n e.printStackTrace();\n }\n }\n}\n
在示例中,我们创建了一个简单的服务器,监听 8080 端口,使用 Selector 处理连接、读取和写入事件。当接收到客户端的数据时,服务器将读取数据并将其打印到控制台,然后向客户端回复 \"Hello, Client!\"。
\n零拷贝是提升 IO 操作性能的一个常用手段,像 ActiveMQ、Kafka 、RocketMQ、QMQ、Netty 等顶级开源项目都用到了零拷贝。
\n零拷贝是指计算机执行 IO 操作时,CPU 不需要将数据从一个存储区域复制到另一个存储区域,从而可以减少上下文切换以及 CPU 的拷贝时间。也就是说,零拷贝主主要解决操作系统在处理 I/O 操作时频繁复制数据的问题。零拷贝的常见实现技术有: mmap+write
、sendfile
和 sendfile + DMA gather copy
。
下图展示了各种零拷贝技术的对比图:
\n| | CPU 拷贝 | DMA 拷贝 | 系统调用 | 上下文切换 |
\n|
Java 阻塞队列的历史可以追溯到 JDK1.5 版本,当时 Java 平台增加了 java.util.concurrent
,即我们常说的 JUC 包,其中包含了各种并发流程控制工具、并发容器、原子类等。这其中自然也包含了我们这篇文章所讨论的阻塞队列。
为了解决高并发场景下多线程之间数据共享的问题,JDK1.5 版本中出现了 ArrayBlockingQueue
和 LinkedBlockingQueue
,它们是带有生产者-消费者模式实现的并发容器。其中,ArrayBlockingQueue
是有界队列,即添加的元素达到上限之后,再次添加就会被阻塞或者抛出异常。而 LinkedBlockingQueue
则由链表构成的队列,正是因为链表的特性,所以 LinkedBlockingQueue
在添加元素上并不会向 ArrayBlockingQueue
那样有着较多的约束,所以 LinkedBlockingQueue
设置队列是否有界是可选的(注意这里的无界并不是指可以添加任务数量的元素,而是说队列的大小默认为 Integer.MAX_VALUE
,近乎于无限大)。
随着 Java 的不断发展,JDK 后续的几个版本又对阻塞队列进行了不少的更新和完善:
\nSynchronousQueue
,一个不存储元素的阻塞队列。TransferQueue
,一个支持更多操作的阻塞队列。DelayQueue
,一个支持延迟获取元素的阻塞队列。阻塞队列就是典型的生产者-消费者模型,它可以做到以下几点:
\n总结一下:阻塞队列就说基于非空和非满两个条件实现生产者和消费者之间的交互,尽管这些交互流程和等待通知的机制实现非常复杂,好在 Doug Lea 的操刀之下已将阻塞队列的细节屏蔽,我们只需调用 put
、take
、offfer
、poll
等 API 即可实现多线程之间的生产和消费。
这也使得阻塞队列在多线程开发中有着广泛的运用,最常见的例子无非是我们的线程池,从源码中我们就能看出当核心线程无法及时处理任务时,这些任务都会扔到 workQueue
中。
public ThreadPoolExecutor(int corePoolSize,\n int maximumPoolSize,\n long keepAliveTime,\n TimeUnit unit,\n BlockingQueue<Runnable> workQueue,\n ThreadFactory threadFactory,\n RejectedExecutionHandler handler) {// ...}\n
简单了解了阻塞队列的历史之后,我们就开始重点讨论本篇文章所要介绍的并发容器——ArrayBlockingQueue
。为了后续更加深入的了解 ArrayBlockingQueue
,我们不妨基于下面几个实例了解以下 ArrayBlockingQueue
的使用。
先看看第一个例子,我们这里会用两个线程分别模拟生产者和消费者,生产者生产完会使用 put
方法生产 10 个元素给消费者进行消费,当队列元素达到我们设置的上限 5 时,put
方法就会阻塞。
\n同理消费者也会通过 take
方法消费元素,当队列为空时,take
方法就会阻塞消费者线程。这里笔者为了保证消费者能够在消费完 10 个元素后及时退出。便通过倒计时门闩,来控制消费者结束,生产者在这里只会生产 10 个元素。当消费者将 10 个元素消费完成之后,按下倒计时门闩,所有线程都会停止。
public class ProducerConsumerExample {\n\n public static void main(String[] args) throws InterruptedException {\n\n // 创建一个大小为 5 的 ArrayBlockingQueue\n ArrayBlockingQueue<Integer> queue = new ArrayBlockingQueue<>(5);\n\n // 创建生产者线程\n Thread producer = new Thread(() -> {\n try {\n for (int i = 1; i <= 10; i++) {\n // 向队列中添加元素,如果队列已满则阻塞等待\n queue.put(i);\n System.out.println(\"生产者添加元素:\" + i);\n }\n } catch (InterruptedException e) {\n e.printStackTrace();\n }\n\n });\n\n CountDownLatch countDownLatch = new CountDownLatch(1);\n\n // 创建消费者线程\n Thread consumer = new Thread(() -> {\n try {\n int count = 0;\n while (true) {\n\n // 从队列中取出元素,如果队列为空则阻塞等待\n int element = queue.take();\n System.out.println(\"消费者取出元素:\" + element);\n ++count;\n if (count == 10) {\n break;\n }\n }\n\n countDownLatch.countDown();\n } catch (InterruptedException e) {\n e.printStackTrace();\n }\n\n });\n\n // 启动线程\n producer.start();\n consumer.start();\n\n // 等待线程结束\n producer.join();\n consumer.join();\n\n countDownLatch.await();\n\n producer.interrupt();\n consumer.interrupt();\n }\n\n}\n
代码输出结果如下,可以看到只有生产者往队列中投放元素之后消费者才能消费,这也就意味着当队列中没有数据的时消费者就会阻塞,等待队列非空再继续消费。
\n生产者添加元素:1\n生产者添加元素:2\n消费者取出元素:1\n消费者取出元素:2\n消费者取出元素:3\n生产者添加元素:3\n生产者添加元素:4\n生产者添加元素:5\n消费者取出元素:4\n生产者添加元素:6\n消费者取出元素:5\n生产者添加元素:7\n生产者添加元素:8\n生产者添加元素:9\n生产者添加元素:10\n消费者取出元素:6\n消费者取出元素:7\n消费者取出元素:8\n消费者取出元素:9\n消费者取出元素:10\n
了解了 put
、take
这两个会阻塞的存和取方法之后,我我们再来看看阻塞队列中非阻塞的入队和出队方法 offer
和 poll
。
如下所示,我们设置了一个大小为 3 的阻塞队列,我们会尝试在队列用 offer 方法存放 4 个元素,然后再从队列中用 poll
尝试取 4 次。
public class OfferPollExample {\n\n public static void main(String[] args) {\n // 创建一个大小为 3 的 ArrayBlockingQueue\n ArrayBlockingQueue<String> queue = new ArrayBlockingQueue<>(3);\n\n // 向队列中添加元素\n System.out.println(queue.offer(\"A\"));\n System.out.println(queue.offer(\"B\"));\n System.out.println(queue.offer(\"C\"));\n\n // 尝试向队列中添加元素,但队列已满,返回 false\n System.out.println(queue.offer(\"D\"));\n\n // 从队列中取出元素\n System.out.println(queue.poll());\n System.out.println(queue.poll());\n System.out.println(queue.poll());\n\n // 尝试从队列中取出元素,但队列已空,返回 null\n System.out.println(queue.poll());\n }\n\n}\n
最终代码的输出结果如下,可以看到因为队列的大小为 3 的缘故,我们前 3 次存放到队列的结果为 true,第 4 次存放时,由于队列已满,所以存放结果返回 false。这也是为什么我们后续的 poll
方法只得到了 3 个元素的值。
true\ntrue\ntrue\nfalse\nA\nB\nC\nnull\n
了解了阻塞存取和非阻塞存取,我们再来看看阻塞队列的一个比较特殊的操作,某些场景下,我们希望能够一次性将阻塞队列的结果存到列表中再进行批量操作,我们就可以使用阻塞队列的 drainTo
方法,这个方法会一次性将队列中所有元素存放到列表,如果队列中有元素,且成功存到 list 中则 drainTo
会返回本次转移到 list 中的元素数,反之若队列为空,drainTo
则直接返回 0。
public class DrainToExample {\n\n public static void main(String[] args) {\n // 创建一个大小为 5 的 ArrayBlockingQueue\n ArrayBlockingQueue<Integer> queue = new ArrayBlockingQueue<>(5);\n\n // 向队列中添加元素\n queue.add(1);\n queue.add(2);\n queue.add(3);\n queue.add(4);\n queue.add(5);\n\n // 创建一个 List,用于存储从队列中取出的元素\n List<Integer> list = new ArrayList<>();\n\n // 从队列中取出所有元素,并添加到 List 中\n queue.drainTo(list);\n\n // 输出 List 中的元素\n System.out.println(list);\n }\n\n}\n
代码输出结果如下
\n[1, 2, 3, 4, 5]\n
自此我们对阻塞队列的使用有了基本的印象,接下来我们就可以进一步了解一下 ArrayBlockingQueue
的工作机制了。
在了解 ArrayBlockingQueue
的具体细节之前,我们先来看看 ArrayBlockingQueue
的类图。
从图中我们可以看出,ArrayBlockingQueue
继承了阻塞队列 BlockingQueue
这个接口,不难猜出通过继承 BlockingQueue
这个接口之后,ArrayBlockingQueue
就拥有了阻塞队列那些常见的操作行为。
同时, ArrayBlockingQueue
还继承了 AbstractQueue
这个抽象类,这个继承了 AbstractCollection
和 Queue
的抽象类,从抽象类的特定和语义我们也可以猜出,这个继承关系使得 ArrayBlockingQueue
拥有了队列的常见操作。
所以我们是否可以得出这样一个结论,通过继承 AbstractQueue
获得队列所有的操作模板,其实现的入队和出队操作的整体框架。然后 ArrayBlockingQueue
通过继承 BlockingQueue
获取到阻塞队列的常见操作并将这些操作实现,填充到 AbstractQueue
模板方法的细节中,由此 ArrayBlockingQueue
成为一个完整的阻塞队列。
为了印证这一点,我们到源码中一探究竟。首先我们先来看看 AbstractQueue
,从类的继承关系我们可以大致得出,它通过 AbstractCollection
获得了集合的常见操作方法,然后通过 Queue
接口获得了队列的特性。
public abstract class AbstractQueue<E>\n extends AbstractCollection<E>\n implements Queue<E> {\n //...\n}\n
对于集合的操作无非是增删改查,所以我们不妨从添加方法入手,从源码中我们可以看到,它实现了 AbstractCollection
的 add
方法,其内部逻辑如下:
Queue
接口的来的 offer
方法,如果 offer
成功则返回 true
。offer
失败,即代表当前元素入队失败直接抛异常。public boolean add(E e) {\n if (offer(e))\n return true;\n else\n throw new IllegalStateException(\"Queue full\");\n}\n
而 AbstractQueue
中并没有对 Queue
的 offer
的实现,很明显这样做的目的是定义好了 add
的核心逻辑,将 offer
的细节交由其子类即我们的 ArrayBlockingQueue
实现。
到此,我们对于抽象类 AbstractQueue
的分析就结束了,我们继续看看 ArrayBlockingQueue
中另一个重要的继承接口 BlockingQueue
。
点开 BlockingQueue
之后,我们可以看到这个接口同样继承了 Queue
接口,这就意味着它也具备了队列所拥有的所有行为。同时,它还定义了自己所需要实现的方法。
public interface BlockingQueue<E> extends Queue<E> {\n\n //元素入队成功返回true,反之则会抛出异常IllegalStateException\n boolean add(E e);\n\n //元素入队成功返回true,反之返回false\n boolean offer(E e);\n\n //元素入队成功则直接返回,如果队列已满元素不可入队则将线程阻塞,因为阻塞期间可能会被打断,所以这里方法签名抛出了InterruptedException\n void put(E e) throws InterruptedException;\n\n //和上一个方法一样,只不过队列满时只会阻塞单位为unit,时间为timeout的时长,如果在等待时长内没有入队成功则直接返回false。\n boolean offer(E e, long timeout, TimeUnit unit)\n throws InterruptedException;\n\n //从队头取出一个元素,如果队列为空则阻塞等待,因为会阻塞线程的缘故,所以该方法可能会被打断,所以签名定义了InterruptedException\n E take() throws InterruptedException;\n\n //取出队头的元素并返回,如果当前队列为空则阻塞等待timeout且单位为unit的时长,如果这个时间段没有元素则直接返回null。\n E poll(long timeout, TimeUnit unit)\n throws InterruptedException;\n\n //获取队列剩余元素个数\n int remainingCapacity();\n\n //删除我们指定的对象,如果成功返回true,反之返回false。\n boolean remove(Object o);\n\n //判断队列中是否包含指定元素\n public boolean contains(Object o);\n\n //将队列中的元素全部存到指定的集合中\n int drainTo(Collection<? super E> c);\n\n //转移maxElements个元素到集合中\n int drainTo(Collection<? super E> c, int maxElements);\n}\n
了解了 BlockingQueue
的常见操作后,我们就知道了 ArrayBlockingQueue
通过继承 BlockingQueue
的方法并实现后,填充到 AbstractQueue
的方法上,由此我们便知道了上文中 AbstractQueue
的 add
方法的 offer
方法是哪里是实现的了。
public boolean add(E e) {\n //AbstractQueue的offer来自下层的ArrayBlockingQueue从BlockingQueue继承并实现的offer方法\n if (offer(e))\n return true;\n else\n throw new IllegalStateException(\"Queue full\");\n}\n
了解 ArrayBlockingQueue
的细节前,我们不妨先看看其构造函数,了解一下其初始化过程。从源码中我们可以看出 ArrayBlockingQueue
有 3 个构造方法,而最核心的构造方法就是下方这一个。
// capacity 表示队列初始容量,fair 表示 锁的公平性\npublic ArrayBlockingQueue(int capacity, boolean fair) {\n //如果设置的队列大小小于0,则直接抛出IllegalArgumentException\n if (capacity <= 0)\n throw new IllegalArgumentException();\n //初始化一个数组用于存放队列的元素\n this.items = new Object[capacity];\n //创建阻塞队列流程控制的锁\n lock = new ReentrantLock(fair);\n //用lock锁创建两个条件控制队列生产和消费\n notEmpty = lock.newCondition();\n notFull = lock.newCondition();\n}\n
这个构造方法里面有两个比较核心的成员变量 notEmpty
(非空) 和 notFull
(非满) ,需要我们格外留意,它们是实现生产者和消费者有序工作的关键所在,这一点笔者会在后续的源码解析中详细说明,这里我们只需初步了解一下阻塞队列的构造即可。
另外两个构造方法都是基于上述的构造方法,默认情况下,我们会使用下面这个构造方法,该构造方法就意味着 ArrayBlockingQueue
用的是非公平锁,即各个生产者或者消费者线程收到通知后,对于锁的争抢是随机的。
public ArrayBlockingQueue(int capacity) {\n this(capacity, false);\n }\n
还有一个不怎么常用的构造方法,在初始化容量和锁的非公平性之后,它还提供了一个 Collection
参数,从源码中不难看出这个构造方法是将外部传入的集合的元素在初始化时直接存放到阻塞队列中。
public ArrayBlockingQueue(int capacity, boolean fair,\n Collection<? extends E> c) {\n //初始化容量和锁的公平性\n this(capacity, fair);\n\n final ReentrantLock lock = this.lock;\n //上锁并将c中的元素存放到ArrayBlockingQueue底层的数组中\n lock.lock();\n try {\n int i = 0;\n try {\n //遍历并添加元素到数组中\n for (E e : c) {\n checkNotNull(e);\n items[i++] = e;\n }\n } catch (ArrayIndexOutOfBoundsException ex) {\n throw new IllegalArgumentException();\n }\n //记录当前队列容量\n count = i;\n //更新下一次put或者offer或用add方法添加到队列底层数组的位置\n putIndex = (i == capacity) ? 0 : i;\n } finally {\n //完成遍历后释放锁\n lock.unlock();\n }\n}\n
ArrayBlockingQueue
阻塞式获取和新增元素对应的就是生产者-消费者模型,虽然它也支持非阻塞式获取和新增元素(例如 poll()
和 offer(E e)
方法,后文会介绍到),但一般不会使用。
ArrayBlockingQueue
阻塞式获取和新增元素的方法为:
put(E e)
:将元素插入队列中,如果队列已满,则该方法会一直阻塞,直到队列有空间可用或者线程被中断。take()
:获取并移除队列头部的元素,如果队列为空,则该方法会一直阻塞,直到队列非空或者线程被中断。这两个方法实现的关键就是在于两个条件对象 notEmpty
(非空) 和 notFull
(非满),这个我们在上文的构造方法中有提到。
接下来笔者就通过两张图让大家了解一下这两个条件是如何在阻塞队列中运用的。
\n\n假设我们的代码消费者先启动,当它发现队列中没有数据,那么非空条件就会将这个线程挂起,即等待条件非空时挂起。然后 CPU 执行权到达生产者,生产者发现队列中可以存放数据,于是将数据存放进去,通知此时条件非空,此时消费者就会被唤醒到队列中使用 take
等方法获取值了。
随后的执行中,生产者生产速度远远大于消费者消费速度,于是生产者将队列塞满后再次尝试将数据存入队列,发现队列已满,于是阻塞队列就将当前线程挂起,等待非满。然后消费者拿着 CPU 执行权进行消费,于是队列可以存放新数据了,发出一个非满的通知,此时挂起的生产者就会等待 CPU 执行权到来时再次尝试将数据存到队列中。
\n简单了解阻塞队列的基于两个条件的交互流程之后,我们不妨看看 put
和 take
方法的源码。
public void put(E e) throws InterruptedException {\n //确保插入的元素不为null\n checkNotNull(e);\n //加锁\n final ReentrantLock lock = this.lock;\n //这里使用lockInterruptibly()方法而不是lock()方法是为了能够响应中断操作,如果在等待获取锁的过程中被打断则该方法会抛出InterruptedException异常。\n lock.lockInterruptibly();\n try {\n //如果count等数组长度则说明队列已满,当前线程将被挂起放到AQS队列中,等待队列非满时插入(非满条件)。\n //在等待期间,锁会被释放,其他线程可以继续对队列进行操作。\n while (count == items.length)\n notFull.await();\n //如果队列可以存放元素,则调用enqueue将元素入队\n enqueue(e);\n } finally {\n //释放锁\n lock.unlock();\n }\n}\n
put
方法内部调用了 enqueue
方法来实现元素入队,我们继续深入查看一下 enqueue
方法的实现细节:
private void enqueue(E x) {\n //获取队列底层的数组\n final Object[] items = this.items;\n //将putindex位置的值设置为我们传入的x\n items[putIndex] = x;\n //更新putindex,如果putindex等于数组长度,则更新为0\n if (++putIndex == items.length)\n putIndex = 0;\n //队列长度+1\n count++;\n //通知队列非空,那些因为获取元素而阻塞的线程可以继续工作了\n notEmpty.signal();\n}\n
从源码中可以看到入队操作的逻辑就是在数组中追加一个新元素,整体执行步骤为:
\nArrayBlockingQueue
底层的数组 items
。putIndex
位置。putIndex
到下一个位置,如果 putIndex
等于队列长度,则说明 putIndex
已经到达数组末尾了,下一次插入则需要 0 开始。(ArrayBlockingQueue
用到了循环队列的思想,即从头到尾循环复用一个数组)count
的值,表示当前队列长度+1。notEmpty.signal()
通知队列非空,消费者可以从队列中获取值了。自此我们了解了 put
方法的流程,为了更加完整的了解 ArrayBlockingQueue
关于生产者-消费者模型的设计,我们继续看看阻塞获取队列元素的 take
方法。
public E take() throws InterruptedException {\n //获取锁\n final ReentrantLock lock = this.lock;\n lock.lockInterruptibly();\n try {\n //如果队列中元素个数为0,则将当前线程打断并存入AQS队列中,等待队列非空时获取并移除元素(非空条件)\n while (count == 0)\n notEmpty.await();\n //如果队列不为空则调用dequeue获取元素\n return dequeue();\n } finally {\n //释放锁\n lock.unlock();\n }\n}\n
理解了 put
方法再看take
方法就很简单了,其核心逻辑和put
方法正好是相反的,比如put
方法在队列满的时候等待队列非满时插入元素(非满条件),而take
方法等待队列非空时获取并移除元素(非空条件)。
take
方法内部调用了 dequeue
方法来实现元素出队,其核心逻辑和 enqueue
方法也是相反的。
private E dequeue() {\n //获取阻塞队列底层的数组\n final Object[] items = this.items;\n @SuppressWarnings(\"unchecked\")\n //从队列中获取takeIndex位置的元素\n E x = (E) items[takeIndex];\n //将takeIndex置空\n items[takeIndex] = null;\n //takeIndex向后挪动,如果等于数组长度则更新为0\n if (++takeIndex == items.length)\n takeIndex = 0;\n //队列长度减1\n count--;\n if (itrs != null)\n itrs.elementDequeued();\n //通知那些被打断的线程当前队列状态非满,可以继续存放元素\n notFull.signal();\n return x;\n}\n
由于dequeue
方法(出队)和上面介绍的 enqueue
方法(入队)的步骤大致类似,这里就不重复介绍了。
为了帮助理解,我专门画了一张图来展示 notEmpty
(非空) 和 notFull
(非满)这两个条件对象是如何控制 ArrayBlockingQueue
的存和取的。
take
或者 poll
等操作取出一个元素之后,就会通知队列非满,此时那些等待非满的生产者就会被唤醒等待获取 CPU 时间片进行入队操作。ArrayBlockingQueue
非阻塞式获取和新增元素的方法为:
offer(E e)
:将元素插入队列尾部。如果队列已满,则该方法会直接返回 false,不会等待并阻塞线程。poll()
:获取并移除队列头部的元素,如果队列为空,则该方法会直接返回 null,不会等待并阻塞线程。add(E e)
:将元素插入队列尾部。如果队列已满则会抛出 IllegalStateException
异常,底层基于 offer(E e)
方法。remove()
:移除队列头部的元素,如果队列为空则会抛出 NoSuchElementException
异常,底层基于 poll()
。peek()
:获取但不移除队列头部的元素,如果队列为空,则该方法会直接返回 null,不会等待并阻塞线程。先来看看 offer
方法,逻辑和 put
差不多,唯一的区别就是入队失败时不会阻塞当前线程,而是直接返回 false
。
public boolean offer(E e) {\n //确保插入的元素不为null\n checkNotNull(e);\n //获取锁\n final ReentrantLock lock = this.lock;\n lock.lock();\n try {\n //队列已满直接返回false\n if (count == items.length)\n return false;\n else {\n //反之将元素入队并直接返回true\n enqueue(e);\n return true;\n }\n } finally {\n //释放锁\n lock.unlock();\n }\n }\n
poll
方法同理,获取元素失败也是直接返回空,并不会阻塞获取元素的线程。
public E poll() {\n final ReentrantLock lock = this.lock;\n //上锁\n lock.lock();\n try {\n //如果队列为空直接返回null,反之出队返回元素值\n return (count == 0) ? null : dequeue();\n } finally {\n lock.unlock();\n }\n }\n
add
方法其实就是对于 offer
做了一层封装,如下代码所示,可以看到 add
会调用没有规定时间的 offer
,如果入队失败则直接抛异常。
public boolean add(E e) {\n return super.add(e);\n }\n\n\npublic boolean add(E e) {\n //调用offer方法如果失败直接抛出异常\n if (offer(e))\n return true;\n else\n throw new IllegalStateException(\"Queue full\");\n }\n
remove
方法同理,调用 poll
,如果返回 null
则说明队列没有元素,直接抛出异常。
public E remove() {\n E x = poll();\n if (x != null)\n return x;\n else\n throw new NoSuchElementException();\n }\n
peek()
方法的逻辑也很简单,内部调用了 itemAt
方法。
public E peek() {\n //加锁\n final ReentrantLock lock = this.lock;\n lock.lock();\n try {\n //当队列为空时返回 null\n return itemAt(takeIndex);\n } finally {\n //释放锁\n lock.unlock();\n }\n }\n\n//返回队列中指定位置的元素\n@SuppressWarnings(\"unchecked\")\nfinal E itemAt(int i) {\n return (E) items[i];\n}\n
在 offer(E e)
和 poll()
非阻塞获取和新增元素的基础上,设计者提供了带有等待时间的 offer(E e, long timeout, TimeUnit unit)
和 poll(long timeout, TimeUnit unit)
,用于在指定的超时时间内阻塞式地添加和获取元素。
public boolean offer(E e, long timeout, TimeUnit unit)\n throws InterruptedException {\n\n checkNotNull(e);\n long nanos = unit.toNanos(timeout);\n final ReentrantLock lock = this.lock;\n lock.lockInterruptibly();\n try {\n //队列已满,进入循环\n while (count == items.length) {\n //时间到了队列还是满的,则直接返回false\n if (nanos <= 0)\n return false;\n //阻塞nanos时间,等待非满\n nanos = notFull.awaitNanos(nanos);\n }\n enqueue(e);\n return true;\n } finally {\n lock.unlock();\n }\n }\n
可以看到,带有超时时间的 offer
方法在队列已满的情况下,会等待用户所传的时间段,如果规定时间内还不能存放元素则直接返回 false
。
public E poll(long timeout, TimeUnit unit) throws InterruptedException {\n long nanos = unit.toNanos(timeout);\n final ReentrantLock lock = this.lock;\n lock.lockInterruptibly();\n try {\n //队列为空,循环等待,若时间到还是空的,则直接返回null\n while (count == 0) {\n if (nanos <= 0)\n return null;\n nanos = notEmpty.awaitNanos(nanos);\n }\n return dequeue();\n } finally {\n lock.unlock();\n }\n }\n
同理,带有超时时间的 poll
也一样,队列为空则在规定时间内等待,若时间到了还是空的,则直接返回 null。
ArrayBlockingQueue
提供了 contains(Object o)
来判断指定元素是否存在于队列中。
public boolean contains(Object o) {\n //若目标元素为空,则直接返回 false\n if (o == null) return false;\n //获取当前队列的元素数组\n final Object[] items = this.items;\n //加锁\n final ReentrantLock lock = this.lock;\n lock.lock();\n try {\n // 如果队列非空\n if (count > 0) {\n final int putIndex = this.putIndex;\n //从队列头部开始遍历\n int i = takeIndex;\n do {\n if (o.equals(items[i]))\n return true;\n if (++i == items.length)\n i = 0;\n } while (i != putIndex);\n }\n return false;\n } finally {\n //释放锁\n lock.unlock();\n }\n}\n
为了帮助理解 ArrayBlockingQueue
,我们再来对比一下上面提到的这些获取和新增元素的方法。
新增元素:
\n| 方法 | 队列满时处理方式 | 方法返回值 |
\n|
《后端面试高频系统设计&场景题》 是我的知识星球的一个内部小册,包含了常见的系统设计案例比如短链系统、秒杀系统以及高频的场景题比如海量数据去重、第三方授权登录。
\n近年来,随着国内的技术面试越来越卷,越来越多的公司开始在面试中考察系统设计和场景问题,以此来更全面的考察求职者,不论是校招还是社招。不过,正常面试全是场景题的情况还是极少的,面试官一般会在面试中穿插一两个系统设计和场景题来考察你。
\n于是,我总结了这份《后端面试高频系统设计&场景题》,包含了常见的系统设计案例比如短链系统、秒杀系统以及高频的场景题比如海量数据去重、第三方授权登录。
\n即使不是准备面试,我也强烈推荐你认真阅读这一系列文章,这对于提升自己系统设计思维和解决实际问题的能力还是非常有帮助的。并且,涉及到的很多案例都可以用到自己的项目上比如抽奖系统设计、第三方授权登录、Redis 实现延时任务的正确方式。
\n《后端面试高频系统设计&场景题》本身是属于《Java 面试指北》的一部分,后面由于内容篇幅较多,因此被单独提了出来。
\n知识星球除了提供了 《Java 面试指北》 、 《Java 必读源码系列》(目前已经整理了 Dubbo 2.6.x 、Netty 4.x、SpringBoot2.1 的源码)、 《手写 RPC 框架》 、《Kafka 常见面试题/知识点总结》 等多个专属小册,还有 读书活动、学习打卡、简历修改、免费提问、海量 Java 优质面试资源以及各种不定时的福利。
\n\n\n下面是星球提供的部分服务(点击下方图片即可获取知识星球的详细介绍):
\n\n我有自己的原则,不割韭菜,用心做内容,真心希望帮助到你!
\n如果你感兴趣的话,不妨花 3 分钟左右看看星球的详细介绍:JavaGuide 知识星球详细介绍。
\n这里再送一个 30 元的星球专属优惠券,数量有限(价格即将上调。老用户续费半价 ,微信扫码即可续费)!
\n\n进入星球之后,记得查看 星球使用指南 (一定要看!!!) 和 星球优质主题汇总 。
\n无任何套路,无任何潜在收费项。用心做内容,不割韭菜!
\n不过, 一定要确定需要再进 。并且, 三天之内觉得内容不满意可以全额退款 。
\n", "image": "https://oss.javaguide.cn/xingqiu/back-end-interview-high-frequency-system-design-and-scenario-questions-fengmian.png", "date_published": "2023-06-15T08:04:34.000Z", "date_modified": "2023-10-26T22:44:02.000Z", "authors": [], "tags": [ "知识星球" ] }, { "title": "分布式锁常见实现方案总结", "url": "https://javaguide.cn/distributed-system/distributed-lock-implementations.html", "id": "https://javaguide.cn/distributed-system/distributed-lock-implementations.html", "summary": " 这是一则或许对你有用的小广告 面试专版:准备 Java 面试的小伙伴可以考虑面试专版: (质量非常高,专为面试打造,配合 JavaGuide 食用效果最佳)。 知识星球:技术专栏/一对一提问/简历修改/求职指南/面试打卡/不定时福利,欢迎加入 。 通常情况下,我们一般会选择基于 Redis 或者 ZooKeeper 实现分布式锁,Redis 用的要更...", "content_html": "这是一则或许对你有用的小广告
\n通常情况下,我们一般会选择基于 Redis 或者 ZooKeeper 实现分布式锁,Redis 用的要更多一点,我这里也先以 Redis 为例介绍分布式锁的实现。
\n不论是本地锁还是分布式锁,核心都在于“互斥”。
\n在 Redis 中, SETNX
命令是可以帮助我们实现互斥。SETNX
即 SET if Not eXists (对应 Java 中的 setIfAbsent
方法),如果 key 不存在的话,才会设置 key 的值。如果 key 已经存在, SETNX
啥也不做。
> SETNX lockKey uniqueValue\n(integer) 1\n> SETNX lockKey uniqueValue\n(integer) 0\n
释放锁的话,直接通过 DEL
命令删除对应的 key 即可。
> DEL lockKey\n(integer) 1\n
为了防止误删到其他的锁,这里我们建议使用 Lua 脚本通过 key 对应的 value(唯一值)来判断。
\n选用 Lua 脚本是为了保证解锁操作的原子性。因为 Redis 在执行 Lua 脚本时,可以以原子性的方式执行,从而保证了锁释放操作的原子性。
\n// 释放锁时,先比较锁对应的 value 值是否相等,避免锁的误释放\nif redis.call(\"get\",KEYS[1]) == ARGV[1] then\n return redis.call(\"del\",KEYS[1])\nelse\n return 0\nend\n
这是一种最简易的 Redis 分布式锁实现,实现方式比较简单,性能也很高效。不过,这种方式实现分布式锁存在一些问题。就比如应用程序遇到一些问题比如释放锁的逻辑突然挂掉,可能会导致锁无法被释放,进而造成共享资源无法再被其他线程/进程访问。
\n为了避免锁无法被释放,我们可以想到的一个解决办法就是:给这个 key(也就是锁) 设置一个过期时间 。
\n127.0.0.1:6379> SET lockKey uniqueValue EX 3 NX\nOK\n
一定要保证设置指定 key 的值和过期时间是一个原子操作!!! 不然的话,依然可能会出现锁无法被释放的问题。
\n这样确实可以解决问题,不过,这种解决办法同样存在漏洞:如果操作共享资源的时间大于过期时间,就会出现锁提前过期的问题,进而导致分布式锁直接失效。如果锁的超时时间设置过长,又会影响到性能。
\n你或许在想:如果操作共享资源的操作还未完成,锁过期时间能够自己续期就好了!
\n对于 Java 开发的小伙伴来说,已经有了现成的解决方案:Redisson 。其他语言的解决方案,可以在 Redis 官方文档中找到,地址:https://redis.io/topics/distlock 。
\n\nRedisson 是一个开源的 Java 语言 Redis 客户端,提供了很多开箱即用的功能,不仅仅包括多种分布式锁的实现。并且,Redisson 还支持 Redis 单机、Redis Sentinel、Redis Cluster 等多种部署架构。
\nRedisson 中的分布式锁自带自动续期机制,使用起来非常简单,原理也比较简单,其提供了一个专门用来监控和续期锁的 Watch Dog( 看门狗),如果操作共享资源的线程还未执行完成的话,Watch Dog 会不断地延长锁的过期时间,进而保证锁不会因为超时而被释放。
\n\n看门狗名字的由来于 getLockWatchdogTimeout()
方法,这个方法返回的是看门狗给锁续期的过期时间,默认为 30 秒(redisson-3.17.6)。
//默认 30秒,支持修改\nprivate long lockWatchdogTimeout = 30 * 1000;\n\npublic Config setLockWatchdogTimeout(long lockWatchdogTimeout) {\n this.lockWatchdogTimeout = lockWatchdogTimeout;\n return this;\n}\npublic long getLockWatchdogTimeout() {\n return lockWatchdogTimeout;\n}\n
renewExpiration()
方法包含了看门狗的主要逻辑:
private void renewExpiration() {\n //......\n Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {\n @Override\n public void run(Timeout timeout) throws Exception {\n //......\n // 异步续期,基于 Lua 脚本\n CompletionStage<Boolean> future = renewExpirationAsync(threadId);\n future.whenComplete((res, e) -> {\n if (e != null) {\n // 无法续期\n log.error(\"Can't update lock \" + getRawName() + \" expiration\", e);\n EXPIRATION_RENEWAL_MAP.remove(getEntryName());\n return;\n }\n\n if (res) {\n // 递归调用实现续期\n renewExpiration();\n } else {\n // 取消续期\n cancelExpirationRenewal(null);\n }\n });\n }\n // 延迟 internalLockLeaseTime/3(默认 10s,也就是 30/3) 再调用\n }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);\n\n ee.setTimeout(task);\n }\n
默认情况下,每过 10 秒,看门狗就会执行续期操作,将锁的超时时间设置为 30 秒。看门狗续期前也会先判断是否需要执行续期操作,需要才会执行续期,否则取消续期操作。
\nWatch Dog 通过调用 renewExpirationAsync()
方法实现锁的异步续期:
protected CompletionStage<Boolean> renewExpirationAsync(long threadId) {\n return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,\n // 判断是否为持锁线程,如果是就执行续期操作,就锁的过期时间设置为 30s(默认)\n \"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then \" +\n \"redis.call('pexpire', KEYS[1], ARGV[1]); \" +\n \"return 1; \" +\n \"end; \" +\n \"return 0;\",\n Collections.singletonList(getRawName()),\n internalLockLeaseTime, getLockName(threadId));\n}\n
可以看出, renewExpirationAsync
方法其实是调用 Lua 脚本实现的续期,这样做主要是为了保证续期操作的原子性。
我这里以 Redisson 的分布式可重入锁 RLock
为例来说明如何使用 Redisson 实现分布式锁:
// 1.获取指定的分布式锁对象\nRLock lock = redisson.getLock(\"lock\");\n// 2.拿锁且不设置锁超时时间,具备 Watch Dog 自动续期机制\nlock.lock();\n// 3.执行业务\n...\n// 4.释放锁\nlock.unlock();\n
只有未指定锁超时时间,才会使用到 Watch Dog 自动续期机制。
\n// 手动给锁设置过期时间,不具备 Watch Dog 自动续期机制\nlock.lock(10, TimeUnit.SECONDS);\n
如果使用 Redis 来实现分布式锁的话,还是比较推荐直接基于 Redisson 来做的。
\n所谓可重入锁指的是在一个线程中可以多次获取同一把锁,比如一个线程在执行一个带锁的方法,该方法中又调用了另一个需要相同锁的方法,则该线程可以直接执行调用的方法即可重入 ,而无需重新获得锁。像 Java 中的 synchronized
和 ReentrantLock
都属于可重入锁。
不可重入的分布式锁基本可以满足绝大部分业务场景了,一些特殊的场景可能会需要使用可重入的分布式锁。
\n可重入分布式锁的实现核心思路是线程在获取锁的时候判断是否为自己的锁,如果是的话,就不用再重新获取了。为此,我们可以为每个锁关联一个可重入计数器和一个占有它的线程。当可重入计数器大于 0 时,则锁被占有,需要判断占有该锁的线程和请求获取锁的线程是否为同一个。
\n实际项目中,我们不需要自己手动实现,推荐使用我们上面提到的 Redisson ,其内置了多种类型的锁比如可重入锁(Reentrant Lock)、自旋锁(Spin Lock)、公平锁(Fair Lock)、多重锁(MultiLock)、 红锁(RedLock)、 读写锁(ReadWriteLock)。
\n\n为了避免单点故障,生产环境下的 Redis 服务通常是集群化部署的。
\nRedis 集群下,上面介绍到的分布式锁的实现会存在一些问题。由于 Redis 集群数据同步到各个节点时是异步的,如果在 Redis 主节点获取到锁后,在没有同步到其他节点时,Redis 主节点宕机了,此时新的 Redis 主节点依然可以获取锁,所以多个应用服务就可以同时获取到锁。
\n\n针对这个问题,Redis 之父 antirez 设计了 Redlock 算法 来解决。
\n\nRedlock 算法的思想是让客户端向 Redis 集群中的多个独立的 Redis 实例依次请求申请加锁,如果客户端能够和半数以上的实例成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁,否则加锁失败。
\n即使部分 Redis 节点出现问题,只要保证 Redis 集群中有半数以上的 Redis 节点可用,分布式锁服务就是正常的。
\nRedlock 是直接操作 Redis 节点的,并不是通过 Redis 集群操作的,这样才可以避免 Redis 集群主从切换导致的锁丢失问题。
\nRedlock 实现比较复杂,性能比较差,发生时钟变迁的情况下还存在安全性隐患。《数据密集型应用系统设计》一书的作者 Martin Kleppmann 曾经专门发文(How to do distributed locking - Martin Kleppmann - 2016)怼过 Redlock,他认为这是一个很差的分布式锁实现。感兴趣的朋友可以看看Redis 锁从面试连环炮聊到神仙打架这篇文章,有详细介绍到 antirez 和 Martin Kleppmann 关于 Redlock 的激烈辩论。
\n实际项目中不建议使用 Redlock 算法,成本和收益不成正比。
\n如果不是非要实现绝对可靠的分布式锁的话,其实单机版 Redis 就完全够了,实现简单,性能也非常高。如果你必须要实现一个绝对可靠的分布式锁的话,可以基于 ZooKeeper 来做,只是性能会差一些。
\nRedis 实现分布式锁性能较高,ZooKeeper 实现分布式锁可靠性更高。实际项目中,我们应该根据业务的具体需求来选择。
\nZooKeeper 分布式锁是基于 临时顺序节点 和 Watcher(事件监听器) 实现的。
\n获取锁:
\n/locks
,客户端获取锁就是在locks
下创建临时顺序节点。/locks/lock1
节点,创建成功之后,会判断 lock1
是否是 /locks
下最小的子节点。lock1
是最小的子节点,则获取锁成功。否则,获取锁失败。/locks/lock0
上注册一个事件监听器。这个监听器的作用是当前一个节点释放锁之后通知客户端 1(避免无效自旋),这样客户端 1 就加锁成功了。释放锁:
\n实际项目中,推荐使用 Curator 来实现 ZooKeeper 分布式锁。Curator 是 Netflix 公司开源的一套 ZooKeeper Java 客户端框架,相比于 ZooKeeper 自带的客户端 zookeeper 来说,Curator 的封装更加完善,各种 API 都可以比较方便地使用。
\nCurator
主要实现了下面四种锁:
InterProcessMutex
:分布式可重入排它锁InterProcessSemaphoreMutex
:分布式不可重入排它锁InterProcessReadWriteLock
:分布式读写锁InterProcessMultiLock
:将多个锁作为单个实体管理的容器,获取锁的时候获取所有锁,释放锁也会释放所有锁资源(忽略释放失败的锁)。CuratorFramework client = ZKUtils.getClient();\nclient.start();\n// 分布式可重入排它锁\nInterProcessLock lock1 = new InterProcessMutex(client, lockPath1);\n// 分布式不可重入排它锁\nInterProcessLock lock2 = new InterProcessSemaphoreMutex(client, lockPath2);\n// 将多个锁作为一个整体\nInterProcessMultiLock lock = new InterProcessMultiLock(Arrays.asList(lock1, lock2));\n\nif (!lock.acquire(10, TimeUnit.SECONDS)) {\n throw new IllegalStateException(\"不能获取多锁\");\n}\nSystem.out.println(\"已获取多锁\");\nSystem.out.println(\"是否有第一个锁: \" + lock1.isAcquiredInThisProcess());\nSystem.out.println(\"是否有第二个锁: \" + lock2.isAcquiredInThisProcess());\ntry {\n // 资源操作\n resource.use();\n} finally {\n System.out.println(\"释放多个锁\");\n lock.release();\n}\nSystem.out.println(\"是否有第一个锁: \" + lock1.isAcquiredInThisProcess());\nSystem.out.println(\"是否有第二个锁: \" + lock2.isAcquiredInThisProcess());\nclient.close();\n
每个数据节点在 ZooKeeper 中被称为 znode,它是 ZooKeeper 中数据的最小单元。
\n我们通常是将 znode 分为 4 大类:
\n/node1/app0000000001
、/node1/app0000000002
。可以看出,临时节点相比持久节点,最主要的是对会话失效的情况处理不一样,临时节点会话消失则对应的节点消失。这样的话,如果客户端发生异常导致没来得及释放锁也没关系,会话失效节点自动被删除,不会发生死锁的问题。
\n使用 Redis 实现分布式锁的时候,我们是通过过期时间来避免锁无法被释放导致死锁问题的,而 ZooKeeper 直接利用临时节点的特性即可。
\n假设不使用顺序节点的话,所有尝试获取锁的客户端都会对持有锁的子节点加监听器。当该锁被释放之后,势必会造成所有尝试获取锁的客户端来争夺锁,这样对性能不友好。使用顺序节点之后,只需要监听前一个节点就好了,对性能更友好。
\n\n\nWatcher(事件监听器),是 ZooKeeper 中的一个很重要的特性。ZooKeeper 允许用户在指定节点上注册一些 Watcher,并且在一些特定事件触发的时候,ZooKeeper 服务端会将事件通知到感兴趣的客户端上去,该机制是 ZooKeeper 实现分布式协调服务的重要特性。
\n
同一时间段内,可能会有很多客户端同时获取锁,但只有一个可以获取成功。如果获取锁失败,则说明有其他的客户端已经成功获取锁。获取锁失败的客户端并不会不停地循环去尝试加锁,而是在前一个节点注册一个事件监听器。
\n这个事件监听器的作用是:当前一个节点对应的客户端释放锁之后(也就是前一个节点被删除之后,监听的是删除事件),通知获取锁失败的客户端(唤醒等待的线程,Java 中的 wait/notifyAll
),让它尝试去获取锁,然后就成功获取锁了。
这里以 Curator 的 InterProcessMutex
对可重入锁的实现来介绍(源码地址:InterProcessMutex.java)。
当我们调用 InterProcessMutex#acquire
方法获取锁的时候,会调用InterProcessMutex#internalLock
方法。
// 获取可重入互斥锁,直到获取成功为止\n@Override\npublic void acquire() throws Exception {\n if (!internalLock(-1, null)) {\n throw new IOException(\"Lost connection while trying to acquire lock: \" + basePath);\n }\n}\n
internalLock
方法会先获取当前请求锁的线程,然后从 threadData
( ConcurrentMap<Thread, LockData>
类型)中获取当前线程对应的 lockData
。 lockData
包含锁的信息和加锁的次数,是实现可重入锁的关键。
第一次获取锁的时候,lockData
为 null
。获取锁成功之后,会将当前线程和对应的 lockData
放到 threadData
中
private boolean internalLock(long time, TimeUnit unit) throws Exception {\n // 获取当前请求锁的线程\n Thread currentThread = Thread.currentThread();\n // 拿对应的 lockData\n LockData lockData = threadData.get(currentThread);\n // 第一次获取锁的话,lockData 为 null\n if (lockData != null) {\n // 当前线程获取过一次锁之后\n // 因为当前线程的锁存在, lockCount 自增后返回,实现锁重入.\n lockData.lockCount.incrementAndGet();\n return true;\n }\n // 尝试获取锁\n String lockPath = internals.attemptLock(time, unit, getLockNodeBytes());\n if (lockPath != null) {\n LockData newLockData = new LockData(currentThread, lockPath);\n // 获取锁成功之后,将当前线程和对应的 lockData 放到 threadData 中\n threadData.put(currentThread, newLockData);\n return true;\n }\n\n return false;\n}\n
LockData
是 InterProcessMutex
中的一个静态内部类。
private final ConcurrentMap<Thread, LockData> threadData = Maps.newConcurrentMap();\n\nprivate static class LockData\n{\n // 当前持有锁的线程\n final Thread owningThread;\n // 锁对应的子节点\n final String lockPath;\n // 加锁的次数\n final AtomicInteger lockCount = new AtomicInteger(1);\n\n private LockData(Thread owningThread, String lockPath)\n {\n this.owningThread = owningThread;\n this.lockPath = lockPath;\n }\n}\n
如果已经获取过一次锁,后面再来获取锁的话,直接就会在 if (lockData != null)
这里被拦下了,然后就会执行lockData.lockCount.incrementAndGet();
将加锁次数加 1。
整个可重入锁的实现逻辑非常简单,直接在客户端判断当前线程有没有获取锁,有的话直接将加锁次数加 1 就可以了。
\n在这篇文章中,我介绍了实现分布式锁的两种常见方式: Redis 和 ZooKeeper。至于具体选择 Redis 还是 ZooKeeper 来实现分布式锁,还是要看业务的具体需求。
\n最后,再分享两篇我觉得写的还不错的文章:
\n\n\n", "image": "https://oss.javaguide.cn/github/javaguide/distributed-system/distributed-lock/distributed-lock-setnx.png", "date_published": "2023-06-13T13:47:00.000Z", "date_modified": "2023-12-30T09:14:13.000Z", "authors": [], "tags": [ "分布式" ] }, { "title": "CopyOnWriteArrayList 源码分析", "url": "https://javaguide.cn/java/collection/copyonwritearraylist-source-code.html", "id": "https://javaguide.cn/java/collection/copyonwritearraylist-source-code.html", "summary": "CopyOnWriteArrayList 简介 在 JDK1.5 之前,如果想要使用并发安全的 List 只能选择 Vector。而 Vector 是一种老旧的集合,已经被淘汰。Vector 对于增删改查等方法基本都加了 synchronized,这种方式虽然能够保证同步,但这相当于对整个 Vector 加上了一把大锁,使得每个方法执行的时候都要去获得...", "content_html": "在 JDK1.5 之前,如果想要使用并发安全的 List
只能选择 Vector
。而 Vector
是一种老旧的集合,已经被淘汰。Vector
对于增删改查等方法基本都加了 synchronized
,这种方式虽然能够保证同步,但这相当于对整个 Vector
加上了一把大锁,使得每个方法执行的时候都要去获得锁,导致性能非常低下。
JDK1.5 引入了 Java.util.concurrent
(JUC)包,其中提供了很多线程安全且并发性能良好的容器,其中唯一的线程安全 List
实现就是 CopyOnWriteArrayList
。关于java.util.concurrent
包下常见并发容器的总结,可以看我写的这篇文章:Java 常见并发容器总结 。
对于大部分业务场景来说,读取操作往往是远大于写入操作的。由于读取操作不会对原有数据进行修改,因此,对于每次读取都进行加锁其实是一种资源浪费。相比之下,我们应该允许多个线程同时访问 List
的内部数据,毕竟对于读取操作来说是安全的。
这种思路与 ReentrantReadWriteLock
读写锁的设计思想非常类似,即读读不互斥、读写互斥、写写互斥(只有读读不互斥)。CopyOnWriteArrayList
更进一步地实现了这一思想。为了将读操作性能发挥到极致,CopyOnWriteArrayList
中的读取操作是完全无需加锁的。更加厉害的是,写入操作也不会阻塞读取操作,只有写写才会互斥。这样一来,读操作的性能就可以大幅度提升。
CopyOnWriteArrayList
线程安全的核心在于其采用了 写时复制(Copy-On-Write) 的策略,从 CopyOnWriteArrayList
的名字就能看出了。
CopyOnWriteArrayList
名字中的“Copy-On-Write”即写时复制,简称 COW。
下面是维基百科对 Copy-On-Write 的介绍,介绍的挺不错:
\n\n\n写入时复制(英语:Copy-on-write,简称 COW)是一种计算机程序设计领域的优化策略。其核心思想是,如果有多个调用者(callers)同时请求相同资源(如内存或磁盘上的数据存储),他们会共同获取相同的指针指向相同的资源,直到某个调用者试图修改资源的内容时,系统才会真正复制一份专用副本(private copy)给该调用者,而其他调用者所见到的最初的资源仍然保持不变。这过程对其他的调用者都是透明的。此作法主要的优点是如果调用者没有修改该资源,就不会有副本(private copy)被创建,因此多个调用者只是读取操作时可以共享同一份资源。
\n
这里再以 CopyOnWriteArrayList
为例介绍:当需要修改( add
,set
、remove
等操作) CopyOnWriteArrayList
的内容时,不会直接修改原数组,而是会先创建底层数组的副本,对副本数组进行修改,修改完之后再将修改后的数组赋值回去,这样就可以保证写操作不会影响读操作了。
可以看出,写时复制机制非常适合读多写少的并发场景,能够极大地提高系统的并发性能。
\n不过,写时复制机制并不是银弹,其依然存在一些缺点,下面列举几点:
\n这里以 JDK1.8 为例,分析一下 CopyOnWriteArrayList
的底层核心源码。
CopyOnWriteArrayList
的类定义如下:
public class CopyOnWriteArrayList<E>\nextends Object\nimplements List<E>, RandomAccess, Cloneable, Serializable\n{\n //...\n}\n
CopyOnWriteArrayList
实现了以下接口:
List
: 表明它是一个列表,支持添加、删除、查找等操作,并且可以通过下标进行访问。RandomAccess
:这是一个标志接口,表明实现这个接口的 List
集合是支持 快速随机访问 的。Cloneable
:表明它具有拷贝能力,可以进行深拷贝或浅拷贝操作。Serializable
: 表明它可以进行序列化操作,也就是可以将对象转换为字节流进行持久化存储或网络传输,非常方便。CopyOnWriteArrayList
中有一个无参构造函数和两个有参构造函数。
// 创建一个空的 CopyOnWriteArrayList\npublic CopyOnWriteArrayList() {\n setArray(new Object[0]);\n}\n\n// 按照集合的迭代器返回的顺序创建一个包含指定集合元素的 CopyOnWriteArrayList\npublic CopyOnWriteArrayList(Collection<? extends E> c) {\n Object[] elements;\n if (c.getClass() == CopyOnWriteArrayList.class)\n elements = ((CopyOnWriteArrayList<?>)c).getArray();\n else {\n elements = c.toArray();\n // c.toArray might (incorrectly) not return Object[] (see 6260652)\n if (elements.getClass() != Object[].class)\n elements = Arrays.copyOf(elements, elements.length, Object[].class);\n }\n setArray(elements);\n}\n\n// 创建一个包含指定数组的副本的列表\npublic CopyOnWriteArrayList(E[] toCopyIn) {\n setArray(Arrays.copyOf(toCopyIn, toCopyIn.length, Object[].class));\n}\n
CopyOnWriteArrayList
的 add()
方法有三个版本:
add(E e)
:在 CopyOnWriteArrayList
的尾部插入元素。add(int index, E element)
:在 CopyOnWriteArrayList
的指定位置插入元素。addIfAbsent(E e)
:如果指定元素不存在,那么添加该元素。如果成功添加元素则返回 true。这里以add(E e)
为例进行介绍:
// 插入元素到 CopyOnWriteArrayList 的尾部\npublic boolean add(E e) {\n final ReentrantLock lock = this.lock;\n // 加锁\n lock.lock();\n try {\n // 获取原来的数组\n Object[] elements = getArray();\n // 原来数组的长度\n int len = elements.length;\n // 创建一个长度+1的新数组,并将原来数组的元素复制给新数组\n Object[] newElements = Arrays.copyOf(elements, len + 1);\n // 元素放在新数组末尾\n newElements[len] = e;\n // array指向新数组\n setArray(newElements);\n return true;\n } finally {\n // 解锁\n lock.unlock();\n }\n}\n
从上面的源码可以看出:
\nadd
方法内部用到了 ReentrantLock
加锁,保证了同步,避免了多线程写的时候会复制出多个副本出来。锁被修饰保证了锁的内存地址肯定不会被修改,并且,释放锁的逻辑放在 finally
中,可以保证锁能被释放。CopyOnWriteArrayList
通过复制底层数组的方式实现写操作,即先创建一个新的数组来容纳新添加的元素,然后在新数组中进行写操作,最后将新数组赋值给底层数组的引用,替换掉旧的数组。这也就证明了我们前面说的:CopyOnWriteArrayList
线程安全的核心在于其采用了 写时复制(Copy-On-Write) 的策略。Arrays.copyOf
复制底层数组,时间复杂度是 O(n) 的,且会占用额外的内存空间。因此,CopyOnWriteArrayList
适用于读多写少的场景,在写操作不频繁且内存资源充足的情况下,可以提升系统的性能表现。CopyOnWriteArrayList
中并没有类似于 ArrayList
的 grow()
方法扩容的操作。\n\n\n
Arrays.copyOf
方法的时间复杂度是 O(n),其中 n 表示需要复制的数组长度。因为这个方法的实现原理是先创建一个新的数组,然后将源数组中的数据复制到新数组中,最后返回新数组。这个方法会复制整个数组,因此其时间复杂度与数组长度成正比,即 O(n)。值得注意的是,由于底层调用了系统级别的拷贝指令,因此在实际应用中这个方法的性能表现比较优秀,但是也需要注意控制复制的数据量,避免出现内存占用过高的情况。
CopyOnWriteArrayList
的读取操作是基于内部数组 array
并没有发生实际的修改,因此在读取操作时不需要进行同步控制和锁操作,可以保证数据的安全性。这种机制下,多个线程可以同时读取列表中的元素。
// 底层数组,只能通过getArray和setArray方法访问\nprivate transient volatile Object[] array;\n\npublic E get(int index) {\n return get(getArray(), index);\n}\n\nfinal Object[] getArray() {\n return array;\n}\n\nprivate E get(Object[] a, int index) {\n return (E) a[index];\n}\n
不过,get
方法是弱一致性的,在某些情况下可能读到旧的元素值。
get(int index)
方法是分两步进行的:
getArray()
获取当前数组的引用;这个过程并没有加锁,所以在并发环境下可能出现如下情况:
\nget(int index)
方法获取值,内部通过getArray()
方法获取到了 array 属性值;CopyOnWriteArrayList
的add
、set
、remove
等修改方法时,内部通过setArray
方法修改了array
属性的值;array
数组中取值。public int size() {\n return getArray().length;\n}\n
CopyOnWriteArrayList
中的array
数组每次复制都刚好能够容纳下所有元素,并不像ArrayList
那样会预留一定的空间。因此,CopyOnWriteArrayList
中并没有size
属性CopyOnWriteArrayList
的底层数组的长度就是元素个数,因此size()
方法只要返回数组长度就可以了。
CopyOnWriteArrayList
删除元素相关的方法一共有 4 个:
remove(int index)
:移除此列表中指定位置上的元素。将任何后续元素向左移动(从它们的索引中减去 1)。boolean remove(Object o)
:删除此列表中首次出现的指定元素,如果不存在该元素则返回 false。boolean removeAll(Collection<?> c)
:从此列表中删除指定集合中包含的所有元素。void clear()
:移除此列表中的所有元素。这里以remove(int index)
为例进行介绍:
public E remove(int index) {\n // 获取可重入锁\n final ReentrantLock lock = this.lock;\n // 加锁\n lock.lock();\n try {\n //获取当前array数组\n Object[] elements = getArray();\n // 获取当前array长度\n int len = elements.length;\n //获取指定索引的元素(旧值)\n E oldValue = get(elements, index);\n int numMoved = len - index - 1;\n // 判断删除的是否是最后一个元素\n if (numMoved == 0)\n // 如果删除的是最后一个元素,直接复制该元素前的所有元素到新的数组\n setArray(Arrays.copyOf(elements, len - 1));\n else {\n // 分段复制,将index前的元素和index+1后的元素复制到新数组\n // 新数组长度为旧数组长度-1\n Object[] newElements = new Object[len - 1];\n System.arraycopy(elements, 0, newElements, 0, index);\n System.arraycopy(elements, index + 1, newElements, index,\n numMoved);\n //将新数组赋值给array引用\n setArray(newElements);\n }\n return oldValue;\n } finally {\n // 解锁\n lock.unlock();\n }\n}\n
CopyOnWriteArrayList
提供了两个用于判断指定元素是否在列表中的方法:
contains(Object o)
:判断是否包含指定元素。containsAll(Collection<?> c)
:判断是否保证指定集合的全部元素。// 判断是否包含指定元素\npublic boolean contains(Object o) {\n //获取当前array数组\n Object[] elements = getArray();\n //调用index尝试查找指定元素,如果返回值大于等于0,则返回true,否则返回false\n return indexOf(o, elements, 0, elements.length) >= 0;\n}\n\n// 判断是否保证指定集合的全部元素\npublic boolean containsAll(Collection<?> c) {\n //获取当前array数组\n Object[] elements = getArray();\n //获取数组长度\n int len = elements.length;\n //遍历指定集合\n for (Object e : c) {\n //循环调用indexOf方法判断,只要有一个没有包含就直接返回false\n if (indexOf(e, elements, 0, len) < 0)\n return false;\n }\n //最后表示全部包含或者制定集合为空集合,那么返回true\n return true;\n}\n
代码:
\n// 创建一个 CopyOnWriteArrayList 对象\nCopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();\n\n// 向列表中添加元素\nlist.add(\"Java\");\nlist.add(\"Python\");\nlist.add(\"C++\");\nSystem.out.println(\"初始列表:\" + list);\n\n// 使用 get 方法获取指定位置的元素\nSystem.out.println(\"列表第二个元素为:\" + list.get(1));\n\n// 使用 remove 方法删除指定元素\nboolean result = list.remove(\"C++\");\nSystem.out.println(\"删除结果:\" + result);\nSystem.out.println(\"列表删除元素后为:\" + list);\n\n// 使用 set 方法更新指定位置的元素\nlist.set(1, \"Golang\");\nSystem.out.println(\"列表更新后为:\" + list);\n\n// 使用 add 方法在指定位置插入元素\nlist.add(0, \"PHP\");\nSystem.out.println(\"列表插入元素后为:\" + list);\n\n// 使用 size 方法获取列表大小\nSystem.out.println(\"列表大小为:\" + list.size());\n\n// 使用 removeAll 方法删除指定集合中所有出现的元素\nresult = list.removeAll(List.of(\"Java\", \"Golang\"));\nSystem.out.println(\"批量删除结果:\" + result);\nSystem.out.println(\"列表批量删除元素后为:\" + list);\n\n// 使用 clear 方法清空列表中所有元素\nlist.clear();\nSystem.out.println(\"列表清空后为:\" + list);\n
输出:
\n列表更新后为:[Java, Golang]\n列表插入元素后为:[PHP, Java, Golang]\n列表大小为:3\n批量删除结果:true\n列表批量删除元素后为:[PHP]\n列表清空后为:[]\n
LinkedList
是一个基于双向链表实现的集合类,经常被拿来和 ArrayList
做比较。关于 LinkedList
和ArrayList
的详细对比,我们 Java 集合常见面试题总结(上)有详细介绍到。
不过,我们在项目中一般是不会使用到 LinkedList
的,需要用到 LinkedList
的场景几乎都可以使用 ArrayList
来代替,并且,性能通常会更好!就连 LinkedList
的作者约书亚 · 布洛克(Josh Bloch)自己都说从来不会使用 LinkedList
。
另外,不要下意识地认为 LinkedList
作为链表就最适合元素增删的场景。我在上面也说了,LinkedList
仅仅在头尾插入或者删除元素的时候时间复杂度近似 O(1),其他情况增删元素的平均时间复杂度都是 O(n) 。
RandomAccess
是一个标记接口,用来表明实现该接口的类支持随机访问(即可以通过索引快速访问元素)。由于 LinkedList
底层数据结构是链表,内存地址不连续,只能通过指针来定位,不支持随机快速访问,所以不能实现 RandomAccess
接口。
这里以 JDK1.8 为例,分析一下 LinkedList
的底层核心源码。
LinkedList
的类定义如下:
public class LinkedList<E>\n extends AbstractSequentialList<E>\n implements List<E>, Deque<E>, Cloneable, java.io.Serializable\n{\n //...\n}\n
LinkedList
继承了 AbstractSequentialList
,而 AbstractSequentialList
又继承于 AbstractList
。
阅读过 ArrayList
的源码我们就知道,ArrayList
同样继承了 AbstractList
, 所以 LinkedList
会有大部分方法和 ArrayList
相似。
LinkedList
实现了以下接口:
List
: 表明它是一个列表,支持添加、删除、查找等操作,并且可以通过下标进行访问。Deque
:继承自 Queue
接口,具有双端队列的特性,支持从两端插入和删除元素,方便实现栈和队列等数据结构。需要注意,Deque
的发音为 \"deck\" [dɛk],这个大部分人都会读错。Cloneable
:表明它具有拷贝能力,可以进行深拷贝或浅拷贝操作。Serializable
: 表明它可以进行序列化操作,也就是可以将对象转换为字节流进行持久化存储或网络传输,非常方便。LinkedList
中的元素是通过 Node
定义的:
private static class Node<E> {\n E item;// 节点值\n Node<E> next; // 指向的下一个节点(后继节点)\n Node<E> prev; // 指向的前一个节点(前驱结点)\n\n // 初始化参数顺序分别是:前驱结点、本身节点值、后继节点\n Node(Node<E> prev, E element, Node<E> next) {\n this.item = element;\n this.next = next;\n this.prev = prev;\n }\n}\n
LinkedList
中有一个无参构造函数和一个有参构造函数。
// 创建一个空的链表对象\npublic LinkedList() {\n}\n\n// 接收一个集合类型作为参数,会创建一个与传入集合相同元素的链表对象\npublic LinkedList(Collection<? extends E> c) {\n this();\n addAll(c);\n}\n
LinkedList
除了实现了 List
接口相关方法,还实现了 Deque
接口的很多方法,所以我们有很多种方式插入元素。
我们这里以 List
接口中相关的插入方法为例进行源码讲解,对应的是add()
方法。
add()
方法有两个版本:
add(E e)
:用于在 LinkedList
的尾部插入元素,即将新元素作为链表的最后一个元素,时间复杂度为 O(1)。add(int index, E element)
:用于在指定位置插入元素。这种插入方式需要先移动到指定位置,再修改指定节点的指针完成插入/删除,因此需要移动平均 n/2 个元素,时间复杂度为 O(n)。// 在链表尾部插入元素\npublic boolean add(E e) {\n linkLast(e);\n return true;\n}\n\n// 在链表指定位置插入元素\npublic void add(int index, E element) {\n // 下标越界检查\n checkPositionIndex(index);\n\n // 判断 index 是不是链表尾部位置\n if (index == size)\n // 如果是就直接调用 linkLast 方法将元素节点插入链表尾部即可\n linkLast(element);\n else\n // 如果不是则调用 linkBefore 方法将其插入指定元素之前\n linkBefore(element, node(index));\n}\n\n// 将元素节点插入到链表尾部\nvoid linkLast(E e) {\n // 将最后一个元素赋值(引用传递)给节点 l\n final Node<E> l = last;\n // 创建节点,并指定节点前驱为链表尾节点 last,后继引用为空\n final Node<E> newNode = new Node<>(l, e, null);\n // 将 last 引用指向新节点\n last = newNode;\n // 判断尾节点是否为空\n // 如果 l 是null 意味着这是第一次添加元素\n if (l == null)\n // 如果是第一次添加,将first赋值为新节点,此时链表只有一个元素\n first = newNode;\n else\n // 如果不是第一次添加,将新节点赋值给l(添加前的最后一个元素)的next\n l.next = newNode;\n size++;\n modCount++;\n}\n\n// 在指定元素之前插入元素\nvoid linkBefore(E e, Node<E> succ) {\n // assert succ != null;断言 succ不为 null\n // 定义一个节点元素保存 succ 的 prev 引用,也就是它的前一节点信息\n final Node<E> pred = succ.prev;\n // 初始化节点,并指明前驱和后继节点\n final Node<E> newNode = new Node<>(pred, e, succ);\n // 将 succ 节点前驱引用 prev 指向新节点\n succ.prev = newNode;\n // 判断尾节点是否为空,为空表示当前链表还没有节点\n if (pred == null)\n first = newNode;\n else\n // succ 节点前驱的后继引用指向新节点\n pred.next = newNode;\n size++;\n modCount++;\n}\n
LinkedList
获取元素相关的方法一共有 3 个:
getFirst()
:获取链表的第一个元素。getLast()
:获取链表的最后一个元素。get(int index)
:获取链表指定位置的元素。// 获取链表的第一个元素\npublic E getFirst() {\n final Node<E> f = first;\n if (f == null)\n throw new NoSuchElementException();\n return f.item;\n}\n\n// 获取链表的最后一个元素\npublic E getLast() {\n final Node<E> l = last;\n if (l == null)\n throw new NoSuchElementException();\n return l.item;\n}\n\n// 获取链表指定位置的元素\npublic E get(int index) {\n // 下标越界检查,如果越界就抛异常\n checkElementIndex(index);\n // 返回链表中对应下标的元素\n return node(index).item;\n}\n
这里的核心在于 node(int index)
这个方法:
// 返回指定下标的非空节点\nNode<E> node(int index) {\n // 断言下标未越界\n // assert isElementIndex(index);\n // 如果index小于size的二分之一 从前开始查找(向后查找) 反之向前查找\n if (index < (size >> 1)) {\n Node<E> x = first;\n // 遍历,循环向后查找,直至 i == index\n for (int i = 0; i < index; i++)\n x = x.next;\n return x;\n } else {\n Node<E> x = last;\n for (int i = size - 1; i > index; i--)\n x = x.prev;\n return x;\n }\n}\n
get(int index)
或 remove(int index)
等方法内部都调用了该方法来获取对应的节点。
从这个方法的源码可以看出,该方法通过比较索引值与链表 size 的一半大小来确定从链表头还是尾开始遍历。如果索引值小于 size 的一半,就从链表头开始遍历,反之从链表尾开始遍历。这样可以在较短的时间内找到目标节点,充分利用了双向链表的特性来提高效率。
\nLinkedList
删除元素相关的方法一共有 5 个:
removeFirst()
:删除并返回链表的第一个元素。removeLast()
:删除并返回链表的最后一个元素。remove(E e)
:删除链表中首次出现的指定元素,如果不存在该元素则返回 false。remove(int index)
:删除指定索引处的元素,并返回该元素的值。void clear()
:移除此链表中的所有元素。// 删除并返回链表的第一个元素\npublic E removeFirst() {\n final Node<E> f = first;\n if (f == null)\n throw new NoSuchElementException();\n return unlinkFirst(f);\n}\n\n// 删除并返回链表的最后一个元素\npublic E removeLast() {\n final Node<E> l = last;\n if (l == null)\n throw new NoSuchElementException();\n return unlinkLast(l);\n}\n\n// 删除链表中首次出现的指定元素,如果不存在该元素则返回 false\npublic boolean remove(Object o) {\n // 如果指定元素为 null,遍历链表找到第一个为 null 的元素进行删除\n if (o == null) {\n for (Node<E> x = first; x != null; x = x.next) {\n if (x.item == null) {\n unlink(x);\n return true;\n }\n }\n } else {\n // 如果不为 null ,遍历链表找到要删除的节点\n for (Node<E> x = first; x != null; x = x.next) {\n if (o.equals(x.item)) {\n unlink(x);\n return true;\n }\n }\n }\n return false;\n}\n\n// 删除链表指定位置的元素\npublic E remove(int index) {\n // 下标越界检查,如果越界就抛异常\n checkElementIndex(index);\n return unlink(node(index));\n}\n
这里的核心在于 unlink(Node<E> x)
这个方法:
E unlink(Node<E> x) {\n // 断言 x 不为 null\n // assert x != null;\n // 获取当前节点(也就是待删除节点)的元素\n final E element = x.item;\n // 获取当前节点的下一个节点\n final Node<E> next = x.next;\n // 获取当前节点的前一个节点\n final Node<E> prev = x.prev;\n\n // 如果前一个节点为空,则说明当前节点是头节点\n if (prev == null) {\n // 直接让链表头指向当前节点的下一个节点\n first = next;\n } else { // 如果前一个节点不为空\n // 将前一个节点的 next 指针指向当前节点的下一个节点\n prev.next = next;\n // 将当前节点的 prev 指针置为 null,,方便 GC 回收\n x.prev = null;\n }\n\n // 如果下一个节点为空,则说明当前节点是尾节点\n if (next == null) {\n // 直接让链表尾指向当前节点的前一个节点\n last = prev;\n } else { // 如果下一个节点不为空\n // 将下一个节点的 prev 指针指向当前节点的前一个节点\n next.prev = prev;\n // 将当前节点的 next 指针置为 null,方便 GC 回收\n x.next = null;\n }\n\n // 将当前节点元素置为 null,方便 GC 回收\n x.item = null;\n size--;\n modCount++;\n return element;\n}\n
unlink()
方法的逻辑如下:
可以参考下图理解(图源:LinkedList 源码分析(JDK 1.8)):
\n\n推荐使用for-each
循环来遍历 LinkedList
中的元素, for-each
循环最终会转换成迭代器形式。
LinkedList<String> list = new LinkedList<>();\nlist.add(\"apple\");\nlist.add(\"banana\");\nlist.add(\"pear\");\n\nfor (String fruit : list) {\n System.out.println(fruit);\n}\n
LinkedList
的遍历的核心就是它的迭代器的实现。
// 双向迭代器\nprivate class ListItr implements ListIterator<E> {\n // 表示上一次调用 next() 或 previous() 方法时经过的节点;\n private Node<E> lastReturned;\n // 表示下一个要遍历的节点;\n private Node<E> next;\n // 表示下一个要遍历的节点的下标,也就是当前节点的后继节点的下标;\n private int nextIndex;\n // 表示当前遍历期望的修改计数值,用于和 LinkedList 的 modCount 比较,判断链表是否被其他线程修改过。\n private int expectedModCount = modCount;\n …………\n}\n
下面我们对迭代器 ListItr
中的核心方法进行详细介绍。
我们先来看下从头到尾方向的迭代:
\n// 判断还有没有下一个节点\npublic boolean hasNext() {\n // 判断下一个节点的下标是否小于链表的大小,如果是则表示还有下一个元素可以遍历\n return nextIndex < size;\n}\n// 获取下一个节点\npublic E next() {\n // 检查在迭代过程中链表是否被修改过\n checkForComodification();\n // 判断是否还有下一个节点可以遍历,如果没有则抛出 NoSuchElementException 异常\n if (!hasNext())\n throw new NoSuchElementException();\n // 将 lastReturned 指向当前节点\n lastReturned = next;\n // 将 next 指向下一个节点\n next = next.next;\n nextIndex++;\n return lastReturned.item;\n}\n
再来看一下从尾到头方向的迭代:
\n// 判断是否还有前一个节点\npublic boolean hasPrevious() {\n return nextIndex > 0;\n}\n\n// 获取前一个节点\npublic E previous() {\n // 检查是否在迭代过程中链表被修改\n checkForComodification();\n // 如果没有前一个节点,则抛出异常\n if (!hasPrevious())\n throw new NoSuchElementException();\n // 将 lastReturned 和 next 指针指向上一个节点\n lastReturned = next = (next == null) ? last : next.prev;\n nextIndex--;\n return lastReturned.item;\n}\n
如果需要删除或插入元素,也可以使用迭代器进行操作。
\nLinkedList<String> list = new LinkedList<>();\nlist.add(\"apple\");\nlist.add(null);\nlist.add(\"banana\");\n\n// Collection 接口的 removeIf 方法底层依然是基于迭代器\nlist.removeIf(Objects::isNull);\n\nfor (String fruit : list) {\n System.out.println(fruit);\n}\n
迭代器对应的移除元素的方法如下:
\n// 从列表中删除上次被返回的元素\npublic void remove() {\n // 检查是否在迭代过程中链表被修改\n checkForComodification();\n // 如果上次返回的节点为空,则抛出异常\n if (lastReturned == null)\n throw new IllegalStateException();\n\n // 获取当前节点的下一个节点\n Node<E> lastNext = lastReturned.next;\n // 从链表中删除上次返回的节点\n unlink(lastReturned);\n // 修改指针\n if (next == lastReturned)\n next = lastNext;\n else\n nextIndex--;\n // 将上次返回的节点引用置为 null,方便 GC 回收\n lastReturned = null;\n expectedModCount++;\n}\n
代码:
\n// 创建 LinkedList 对象\nLinkedList<String> list = new LinkedList<>();\n\n// 添加元素到链表末尾\nlist.add(\"apple\");\nlist.add(\"banana\");\nlist.add(\"pear\");\nSystem.out.println(\"链表内容:\" + list);\n\n// 在指定位置插入元素\nlist.add(1, \"orange\");\nSystem.out.println(\"链表内容:\" + list);\n\n// 获取指定位置的元素\nString fruit = list.get(2);\nSystem.out.println(\"索引为 2 的元素:\" + fruit);\n\n// 修改指定位置的元素\nlist.set(3, \"grape\");\nSystem.out.println(\"链表内容:\" + list);\n\n// 删除指定位置的元素\nlist.remove(0);\nSystem.out.println(\"链表内容:\" + list);\n\n// 删除第一个出现的指定元素\nlist.remove(\"banana\");\nSystem.out.println(\"链表内容:\" + list);\n\n// 获取链表的长度\nint size = list.size();\nSystem.out.println(\"链表长度:\" + size);\n\n// 清空链表\nlist.clear();\nSystem.out.println(\"清空后的链表:\" + list);\n
输出:
\n索引为 2 的元素:banana\n链表内容:[apple, orange, banana, grape]\n链表内容:[orange, banana, grape]\n链表内容:[orange, grape]\n链表长度:2\n清空后的链表:[]\n
\n\n推荐语:这是我在两年前看到的一篇对我触动比较深的文章。确实要学会适应变化,并积累能力。积累解决问题的能力,优化思考方式,拓宽自己的认知。
\n\n
苦海无边,回头无岸。
\n晃晃悠悠的,在互联网行业工作了五年,默然回首,你看哪里像灯火阑珊处?
\n初入职场,大部分程序员会觉得苦学技术,以后会顺风顺水升职加薪,这样的想法没有错,但是不算全面,五年后你会不会继续做技术写代码这是核心问题。
\n初入职场,会觉得努力加班可以不断提升能力,可以学到技术的公司就算薪水低点也可以接受,但是五年之后会认为加班都是在不断挤压自己的上升空间,薪水低是人生的天花板。
\n这里想说的关键问题就是:初入职场的认知和想法大部分不会再适用于五年后的认知。
\n工作五年之后面临的最大压力就是选择:职场天花板,技术能力天花板,薪水天花板,三十岁天花板。
\n如何面对这些问题,是大部分程序员都在思考和纠结的。做选择的唯一参考点就是:利益最大化,这里可以理解为职场更好的升职加薪,顺风顺水。
\n五年,变化最大不是工作经验,能力积累,而是心态,清楚的知道现实和理想之间是存在巨大的差距。
\n回首自己的职场五年,最认可的一句话就是:学会适应变化,并积累能力。
\n变化的就是,五年的时间技术框架更新迭代,开发工具的变迁,公司环境队友的更换,甚至是不同城市的流浪,想着能把肉体和灵魂安放在一处,有句很经典的话就是:唯一不变的就是变化本身。
\n要积累的是:解决问题的能力,思考方式,拓宽认知。
\n这种很难直白的描述,属于个人认知的范畴,不同的人有不一样的看法,所以只能站在大众化的角度去思考。
\n首先聊聊技术,大部分小白级别的,都希望自己的技术能力不断提高,争取做到架构师级别,但是站在当前的互联网环境中,这种想法实现难度还是偏高,这里既不是打击也不是为了抬杠。
\n可以观察一下现状,技术团队大的 20-30 人,小的 10-15 人,能有一个架构师去专门管理底层框架都是少有现象。
\n这个问题的原因很多,首先架构师的成本过高,环境架构也不是需要经常升级,说的难听点可能框架比项目生命周期更高。
\n所以大部分公司的大部分业务,基于现有大部分成熟的开源框架都可以解决,这也就导致架构师这个角色通常由项目主管代替或者级别较高的开发直接负责,这就是现实情况。
\n这就导致技术框架的选择思路就是:只选对的。即这方面的人才多,开源解决方案多,以此降低技术方面对公司业务发展的影响。
\n那为什么还要不断学习和积累技术能力?如果没有这个能力,程序员岗位可能根本走不了五年之久,需要用技术深度积累不断解决工作中的各种问题,用技术的广度提升自己实现业务需求的认知边界,这是安放肉体的根本保障。
\n这就是导致很多五年以后的程序员压力陡然升高的原因,走向管理岗的另一个壁垒就是业务思维和认知。
\n程序员该不该用心研究业务,这个问题真的没有纠结的必要,只要不是纯技术型的公司,都需要面对业务。
\n不管技术、运营、产品、管理层,都是在面向业务工作。
\n从自己职场轨迹来看,五年变化最大就是解决业务问题的能力,职场之初面对很多业务场景都不知道如何下手,到几年之后设计业务的解决方案。
\n这是大部分程序员在职场前五年跳槽就能涨薪的根本原因,面对业务场景,基于积累的经验和现有的开源工具,能快速给出合理的解决思路和实现过程。
\n工作五年可能对技术底层的清晰程度都没有初入职场的小白清楚,但是写的程序却可以避开很多坑坑洼洼,对于业务的审视也是很细节全面。
\n解决业务能力的积累,对于技术视野的宽度需求更甚,比如职场初期对于海量数据的处理束手无策,但是在工作几年之后见识数据行业的技术栈,真的就是技术选型的视野问题。
\n什么是衡量技术能力的标准?站在一个共识的角度上看:系统的架构与代码设计能适应业务的不断变化和各种需求。
\n相对比与技术,业务的变化更加快速频繁,高级工程师或者架构师之所以薪资高,这些角色一方面能适应业务的迭代,并且在工作中具有一定前瞻性,会考虑业务变化的情况下代码复用逻辑,这样的能力是需要一定的技术视野和业务思维的沉淀。
\n所以职场中:业务能说的井井有条,代码能写的明明白白,得到机会的可能性更大。
\n从理性的角度看技术和业务两个方面,能让大部分人职场走的平稳顺利,但是不同的阶段对两者的平衡和选择是不一样的。
\n在思考如何选择的时候,可以参考二八原则的逻辑,即在任何一组东西中,最重要的只占其中一小部分,约 20%,其余 80%尽管是多数,却是次要的,因此又称二八定律。
\n个人真的非常喜欢这个原则,大部分人都不是天才,所以很难三心二意同时做好几件事情,在同一时间段内应该集中精力做好一件事件。
\n但是单纯的二八原则模式可能不适应大部分职场初期的人,因为初期要学习很多内容,如何在职场生存:专业能力,职场关系,为人处世,产品设计等等。
\n当然这些东西不是都要用心刻意学习,但是合理安排二二六原则或其他组合是更明智的,首先是专业能力要重点练习,其次可以根据自己的兴趣合理选择一到两个方面去慢慢了解,例如产品,运营,运维,数据等,毕竟三五年以后会不会继续写代码很难说,多给自己留个机会总是有备无患。
\n在职场初期,基本都是从技术角度去思考问题,如何快速提升自己的编码能力,在公司能稳定是首要目标,因此大部分时间都是在做基础编码和学习规范,这时可能 90%的心思都是放在基础编码上,另外 10%会学习环境架构。
\n最多一到两年,就会开始独立负责模块需求开发,需要自己设计整个代码思路,这里业务就会进入视野,要懂得业务上下游关联关系,学会思考如何设计代码结构,才能在需求变动的情况下代码改动较少,这个时候可能就会放 20%的心思在业务方面,30%学习架构方式。
\n三到五年这个时间段,是解决问题能力提升最快的时候,因为这个阶段的程序员基本都是在开发核心业务链路,例如交易、支付、结算、智能商业等模块,需要对业务整体有较清晰的把握能力,不然就是给自己挖坑,这个阶段要对业务流付出大量心血思考。
\n越是核心的业务线,越是容易爆发各种问题,如果在日常工作中不花心思处理各种细节问题,半夜异常自动的消息和邮件总是容易让人憔悴。
\n所以努力学习技术是提升自己,培养自己的业务认知也同样重要,个人认为这二者的分量平分秋色,只是需要在合适的阶段做出合理的权重划分。
\n基于技术能力和业务思维,学会在职场做选择和生存,这些是职场前五年一路走来的最大体会。
\n不管是技术还是业务,这两个概念依旧是个很大的命题,不容易把握,所以学会理清这两个方面能力中的公共模块是关键。
\n不管技术还是业务,都不可能从一家公司完全复制到另一家公司,但是可以把一家公司的技术框架,业务解决方案学会,并且带到另一家公司,例如技术领域内的架构、设计、流程、数据管理,业务领域内的思考方式、产品逻辑、分析等,这些是核心能力并且是大部分公司人才招聘的要求,所以这些才是工作中需要重点积累的。
\n人的精力是有限的,而且面对三十这个天花板,各种事件也会接连而至,在职场中学会合理安排时间并不断提升核心能力,这样才能保证自己的竞争力。
\n职场就像苦海无边,回首望去可能也没有岸边停泊,但是要具有换船的能力或者有个小木筏也就大差不差了。
\n\n", "date_published": "2023-06-04T16:54:06.000Z", "date_modified": "2023-12-30T09:14:13.000Z", "authors": [ { "name": "知了一笑" } ], "tags": [ "技术文章精选集" ] }, { "title": "使用建议", "url": "https://javaguide.cn/javaguide/use-suggestion.html", "id": "https://javaguide.cn/javaguide/use-suggestion.html", "summary": "对于不准备面试的同学来说 ,本文档倾向于给你提供一个比较详细的学习路径,目录清晰,让你对于 Java 整体的知识体系有一个清晰认识。你可以跟着视频、书籍或者官方文档学习完某个知识点之后,然后来这里找对应的总结,帮助你更好地掌握对应的知识点。甚至说,你在有编程基础的情况下,想要学习某个知识点的话,可以直接看我的总结,这样学习效率会非常高。 对于准备面试的...", "content_html": "对于不准备面试的同学来说 ,本文档倾向于给你提供一个比较详细的学习路径,目录清晰,让你对于 Java 整体的知识体系有一个清晰认识。你可以跟着视频、书籍或者官方文档学习完某个知识点之后,然后来这里找对应的总结,帮助你更好地掌握对应的知识点。甚至说,你在有编程基础的情况下,想要学习某个知识点的话,可以直接看我的总结,这样学习效率会非常高。
\n对于准备面试的同学来说 ,本文档涵盖 Java 程序员所需要掌握的核心知识的常见面试问题总结。
\n大部分人看 JavaGuide 应该都是为了准备技术八股文。那如何才能更高效地准备技术八股文?
\n对于技术八股文来说,尽量不要死记硬背,这种方式非常枯燥且对自身能力提升有限!但是!想要一点不背是不太现实的,只是说要结合实际应用场景和实战来理解记忆。
\n我一直觉得面试八股文最好是和实际应用场景和实战相结合。很多同学现在的方向都错了,上来就是直接背八股文,硬生生学成了文科,那当然无趣了。
\n举个例子:你的项目中需要用到 Redis 来做缓存,你对照着官网简单了解并实践了简单使用 Redis 之后,你去看了 Redis 对应的八股文。你发现 Redis 可以用来做限流、分布式锁,于是你去在项目中实践了一下并掌握了对应的八股文。紧接着,你又发现 Redis 内存不够用的情况下,还能使用 Redis Cluster 来解决,于是你就又去实践了一下并掌握了对应的八股文。
\n而且, 面试中有水平的面试官都是根据你的项目经历来顺带着问一些技术八股文 。
\n举个例子:你的项目用到了消息队列,那面试官可能就会问你:为什么使用消息队列?项目中什么模块用到了消息队列?如何保证消息不丢失?如何保证消息的顺序性?(结合你使用的具体的消息队列来准备)……。
\n一定要记住你的主要目标是理解和记关键词,而不是像背课文一样一字一句地记下来!
\n另外,记录博客或者用自己的理解把对应的知识点讲给别人听也是一个不错的选择。
\n最后,准备技术面试的同学一定要定期复习(自测的方式非常好),不然确实会遗忘的。
\n", "date_published": "2023-05-22T02:13:07.000Z", "date_modified": "2023-10-26T22:44:02.000Z", "authors": [], "tags": [ "走近项目" ] }, { "title": "十年大厂成长之路", "url": "https://javaguide.cn/high-quality-technical-articles/advanced-programmer/ten-years-of-dachang-growth-road.html", "id": "https://javaguide.cn/high-quality-technical-articles/advanced-programmer/ten-years-of-dachang-growth-road.html", "summary": " 推荐语:这篇文章的作者有着丰富的工作经验,曾在大厂工作了 12 年。结合自己走过的弯路和接触过的优秀技术人,他总结出了一些对于个人成长具有普遍指导意义的经验和特质。 原文地址: https://mp.weixin.qq.com/s/vIIRxznpRr5yd6IVyNUW2w 最近这段时间,有好几个年轻的同学和我聊到自己的迷茫。其中有关于技术成长的、...", "content_html": "\n\n推荐语:这篇文章的作者有着丰富的工作经验,曾在大厂工作了 12 年。结合自己走过的弯路和接触过的优秀技术人,他总结出了一些对于个人成长具有普遍指导意义的经验和特质。
\n\n
最近这段时间,有好几个年轻的同学和我聊到自己的迷茫。其中有关于技术成长的、有关于晋升的、有关于择业的。我很高兴他们愿意听我这个“过来人”分享自己的经验。
\n我自己毕业后进入大厂,在大厂工作 12 年,我说的内容都来自于我自己或者身边人的真实情况。尤其,我会把 【我自己走过的弯路】 和 【我看到过的优秀技术人的特质】 相结合来给出建议。
\n这些内容我觉得具有普遍的指导意义,所以决定做个整理分享出来。我相信,无论你在大厂还是小厂,如果你相信这些建议,或早或晚他们会帮助到你。
\n我自己工作 12 年,走了些弯路,所以我就来讲讲,“在一个技术人 10 年的发展过程中,应该注意些什么”。我们把内容分为两块:
\n应届生刚进入到工作时,会有各种不适应。比如写好的代码会被反复打回、和团队老司机讨论技术问题会有一堆问号、不敢提问和质疑、碰到问题一个人使劲死磕等等。
\n简单来说就是,即使日以继夜地埋头苦干,最后也无法顺利的开展工作。
\n这个阶段最重要的几个点:
\n【多看多模仿】:比如写代码的时候,不要就像在学校完成书本作业那样只关心功能是否正确,还要关心模块的设计、异常的处理、代码的可读性等等。在你还没有了解这些内容的精髓之前,也要照猫画虎地模仿起来,慢慢地你就会越来越明白真实世界的代码是怎么写的,以及为什么要这么写。
\n做技术方案的时候也是同理,技术文档的要求你也许并不理解,但你可以先参考已有文档写起来。
\n【脸皮厚一点】:不懂就问,你是新人大家都是理解的。你做的各种方案也可以多找老司机们 review,不要怕被看笑话。
\n【关注工作方式】:比如发现需求在计划时间完不成就要尽快报风险、及时做好工作内容的汇报(例如周报)、开会后确定会议结论和 todo 项、承诺时间就要尽力完成、严格遵循公司的要求(例如发布规范、权限规范等)
\n一般来说,工作 2 年后,你就应该成为一个职业人。老板可以相信任何工作交到你的手里,不会出现“意外”(例如一个重要需求明天要上线了,突然被告知上不了)。
\n工作两年后,对业务以及现有系统的了解已经到达了一定的程度,技术同学会开始承担更有难度的技术挑战。
\n例如需要将性能提升到某一个水位、例如需要对某一个重要模块进行重构、例如有个重要的项目需要协同 N 个团队一起完成。
\n可见,上述的这些技术问题,难度都已经远远超过一个普通的需求。解决这些问题需要有一定的技术能力,同时也需要具备更高的协同能力。
\n这个阶段最重要的几个点:
\n【技术能力提升】:无论是公司内还是公司外的技术内容,都要多做主动的学习。基本上这个阶段的技术难题都集中在【性能】【稳定性】和【扩展性】上,而这些内容在业界都是有成型的方法论的。
\n【主人翁精神】:技术难题除了技术方案设计及落地外,背后还有一系列的其他工作。例如上线后对效果的观测、重点项目对于上下游改造和风险的了解程度、对于整个技改后续的计划(二期、三期的优化思路)等。
\n在工作四年后,基本上你成为了团队的一、二号技术位。很多技术难题即使不是你来落地,也是由你来决定方案。你会做调研、会做方案对比、会考虑整个技改的生命周期。
\n技术尖兵重点在于解决某一个具体的技术难题或者重点项目。而下一步的发展方向,就是能够承担起来一整个“业务板块”,也就是“领域技术专家”。
\n想要承担一整个“业务板块”需要 【对业务领域有深刻的理解,同时基于这些理解来规划技术的发展方向】 。
\n拿支付做个例子。简单的支付功能其实很容易完成,只要处理好和双联(网联和银联)的接口调用(成功、失败、异常)即可。但在很多背景下,支付没有那么简单。
\n例如,支付是一个用户敏感型操作,非常强调用户体验,如何能兼顾体验和接口的不稳定?支付接口还需要承担费用,同步和异步的接口费用不同,如何能够降本?支付接口往往还有限额等。这一系列问题的背后涉及到很多技术的设计,包括异步化、补偿设计、资金流设计、最终一致性设计等等。
\n这个阶段最重要的几个点:
\n【深入理解行业及趋势】:密切关注行业的各种变化(新鲜的玩法、政策的变动、竞对的策略、科技等外在因素的影响等等),和业务同学加强沟通。
\n【深入了解行业解决方案】:充分对标已有的国内外技术方案,做深入学习和尝试,评估建设及运维成本,结合业务趋势制定计划。
\n其实很多时候,如果能做到专家,基本也是一个 TL 的角色了,但这并不代表正在执行 TL 的职责。
\n专家虽然已经可以做到“为业务发展而规划好技术发展”,但问题是要怎么落地呢?显然,靠一个人的力量是不可能完成建设的。所以,这里的 TL 更多强调的不是“领导”这个职位,而是 【通过聚合一个团队的力量来实施技术规划】 。
\n所以,这里的 TL 需要具备【团队技术培养】【合理分配资源】【确认工作优先级】【激励与奖惩】等各种能力。
\n这个阶段最重要的几个点:
\n【学习管理学】:这里的管理学当然不是指 PUA,而是指如何在每个同学都有各自诉求的现实背景下,让个人目标和团队目标相结合,产生向前发展的动力。
\n【始终扎根技术】:很多时候,工作重心偏向管理以后,就会荒废技术。但事实是,一个优秀的领导永远是一个优秀的技术人。参与一起讨论技术方案并给予指导、不断扩展自己的技术宽度、保持对技术的好奇心,这些是让一个技术领导持续拥有向心力的关键。
\n下面来聊聊在十年间我们可能会碰到的一些重要选择。这些都是真实的血与泪的教训。
\n大厂都有转岗的机制。转岗可以帮助员工寻找自己感兴趣的方向,也可以帮助新型团队招募有即战力的同学。
\n转岗看似只是在公司内部变动,但你需要谨慎决定。
\n本人转岗过多次。虽然还在同一家公司,但转岗等同于换工作。无论是领域沉淀、工作内容、信任关系、协作关系都是从零开始。
\n针对转岗我的建议是:**如果你是想要拓宽自己的技术广度,也就是抱着提升技术能力的想法,我觉得可以转岗。但如果你想要晋升,不建议你转岗。**晋升需要在一个领域的持续积淀和在一个团队信任感的持续建立。
\n当然,转岗可能还有其他原因,例如家庭原因、身体原因等,这个不展开讨论了。
\n跳槽和转岗一样,往往有很多因素造成,不能一概而论,我仅以几个场景来说:
\n【晋升失败】:扪心自问,如果你觉得自己确实还不够格,那你就踏踏实实继续努力。如果你觉得评委有失偏颇,你可以尝试去外面面试一下,让市场来给你答案。
\n【成长局限】:觉得自己做的事情没有挑战,无法成长。你可以和老板聊一下,有可能是因为你没有看到其中的挑战,也有可能老板没有意识到你的“野心”。
\n【氛围不适】:一般来自于新入职或者领导更换,这种情况下不适是正常的。我的建议是,如果一个环境是“对事不对人”的,那就可以留下来,努力去适应,这种不适应只是做事方式不同导致的。但如果这个环境是“对人不对事”的话,走吧。
\n我们跳槽的时候往往会同时面试好几家公司。行情好的时候,往往可以收到多家 offer,那么我们要如何选择呢?
\n考虑一个 offer 往往有这几点:【公司品牌】【薪资待遇】【职级职称】【技术背景】。每个同学其实都有自己的诉求,所以无论做什么选择都没有对错之分。
\n我的一个建议是:你要关注新岗位的空间,这个空间是有希望满足你的期待的。
\n比如,你想成为架构师,那新岗位是否有足够的技术挑战来帮助你提升技术能力,而不仅仅是疲于奔命地应付需求?
\n比如,你想往技术管理发展,那新岗位是否有带人的机会?是否有足够的问题需要搭建团队来解决?
\n比如,你想扎根在某个领域持续发展(例如电商、游戏),那新岗位是不是延续这个领域,并且可以碰到更多这个领域的问题?
\n当然,如果薪资实在高到无法拒绝,以上参考可以忽略!
\n以上就是我对互联网从业技术人员十年成长之路的心得,希望在你困惑和关键选择的时候可以帮助到你。如果我的只言片语能够在未来的某个时间帮助到你哪怕一点,那将是我莫大的荣幸。
\n\n", "date_published": "2023-05-15T09:49:43.000Z", "date_modified": "2023-12-30T09:14:13.000Z", "authors": [ { "name": "CodingBetterLife" } ], "tags": [ "技术文章精选集" ] }, { "title": "32条总结教你提升职场经验", "url": "https://javaguide.cn/high-quality-technical-articles/work/32-tips-improving-career.html", "id": "https://javaguide.cn/high-quality-technical-articles/work/32-tips-improving-career.html", "summary": " 推荐语:阿里开发者的一篇职场经验的分享。 原文地址: https://mp.weixin.qq.com/s/6BkbGekSRTadm9j7XUL13g 成长的捷径 入职伊始谦逊的态度是好的,但不要把“我是新人”作为心理安全线; 写一篇技术博客大概需要两周左右,但可能是最快的成长方式; 一定要读两本书:金字塔原理、高效能人士的七个习惯(这本书名字像成...", "content_html": "\n\n推荐语:阿里开发者的一篇职场经验的分享。
\n\n
\n\n最后一条大概意思就是有时候我们会在意自己在聚光灯下(述职、晋升、周报、汇报等)的表现,以为大家会根据这个评价自己。实际上日常是怎么完成业务需求、帮助身边同学、创造价值的,才是大家评价自己的依据,而且每个人是什么样的特质,合作过三次的伙伴就可以精准评价,在聚光灯下的表演只能骗自己。
\n
\n\n上级、主管是泛指,开发对口的 PD 主管等也在范围内。
\n
不要传播负面情绪,不要总是抱怨;
\n对上级不卑不亢更容易获得尊重,但不要当众反驳对方观点,分歧私下沟通;
\n好好做向上管理,尤其是对齐预期,沟通绩效出现 Surprise 双方其实都有责任,但倒霉的是自己;
\n尽量站在主管角度想问题:
\n\n\nManager 有下属,Leader 有追随者,管理者不需要很多,但人人都可以是 Leader。
\n
JVM 线上问题排查和性能调优也是面试常问的一个问题,尤其是社招中大厂的面试。
\n这篇文章,我会分享一些我看到的相关的案例。
\n下面是正文。
\n\njvisualvm
分析 dump 文件(MAT 也能分析)。where
条件的全表查询应该默认增加一个合适的limit
作为限制,防止这种问题拖垮整个系统生产事故-记一次特殊的 OOM 排查 - 程语有云 - 2023
\n-Xmn
参数(控制 Young 区的大小)总是应当小于-Xmx
参数(控制堆内存的最大大小),否则就会触发 OOM 错误。一次大量 JVM Native 内存泄露的排查分析(64M 问题) - 掘金 - 2022
\nYGC 问题排查,又让我涨姿势了! - IT 人的职场进阶 - 2021
\n听说 JVM 性能优化很难?今天我小试了一把! - 陈树义 - 2021
\n通过观察 GC 频率和停顿时间,来进行 JVM 内存空间调整,使其达到最合理的状态。调整过程记得小步快跑,避免内存剧烈波动影响线上服务。 这其实是最为简单的一种 JVM 性能调优方式了,可以算是粗调吧。
\n你们要的线上 GC 问题案例来啦 - 编了个程 - 2021
\nJava 中 9 种常见的 CMS GC 问题分析与解决 - 美团技术团 - 2020
\n这篇文章共 2w+ 字,详细介绍了 GC 基础,总结了 CMS GC 的一些常见问题分析与解决办法。
\n给祖传系统做了点 GC 调优,暂停时间降低了 90% - 京东云技术团队 - 2023
\n这篇文章提到了一个在规则引擎系统中遇到的 GC(垃圾回收)问题,主要表现为系统在启动后发生了一次较长的 Young GC(年轻代垃圾回收)导致性能下降。经过分析,问题的核心在于动态对象年龄判定机制,它导致了过早的对象晋升,引起了长时间的垃圾回收。
\n\n", "date_published": "2023-05-10T08:36:34.000Z", "date_modified": "2023-12-28T05:55:34.000Z", "authors": [], "tags": [ "Java" ] }, { "title": "分布式ID设计指南", "url": "https://javaguide.cn/distributed-system/distributed-id-design.html", "id": "https://javaguide.cn/distributed-system/distributed-id-design.html", "summary": " 提示 看到百度 Geek 说的一篇结合具体场景聊分布式 ID 设计的文章,感觉挺不错的。于是,我将这篇文章的部分内容整理到了这里。原文传送门:分布式 ID 生成服务的技术原理和项目实战 。 网上绝大多数的分布式 ID 生成服务,一般着重于技术原理剖析,很少见到根据具体的业务场景去选型 ID 生成服务的文章。 本文结合一些使用场景,进一步探讨业务场景中...", "content_html": "提示
\n看到百度 Geek 说的一篇结合具体场景聊分布式 ID 设计的文章,感觉挺不错的。于是,我将这篇文章的部分内容整理到了这里。原文传送门:分布式 ID 生成服务的技术原理和项目实战 。
\n网上绝大多数的分布式 ID 生成服务,一般着重于技术原理剖析,很少见到根据具体的业务场景去选型 ID 生成服务的文章。
\n本文结合一些使用场景,进一步探讨业务场景中对 ID 有哪些具体的要求。
\n我们在商场买东西一码付二维码,下单生成的订单号,使用到的优惠券码,联合商品兑换券码,这些是在网上购物经常使用到的单号,那么为什么有些单号那么长,有些只有几位数?有些单号一看就知道年月日的信息,有些却看不出任何意义?下面展开分析下订单系统中不同场景的 id 服务的具体实现。
\n我们常见的一码付,指的是一个二维码可以使用支付宝或者微信进行扫码支付。
\n二维码的本质是一个字符串。聚合码的本质就是一个链接地址。用户使用支付宝微信直接扫一个码付钱,不用担心拿支付宝扫了微信的收款码或者用微信扫了支付宝的收款码,这极大减少了用户扫码支付的时间。
\n实现原理是当客户用 APP 扫码后,网站后台就会判断客户的扫码环境。(微信、支付宝、QQ 钱包、京东支付、云闪付等)。
\n判断扫码环境的原理就是根据打开链接浏览器的 HTTP header。任何浏览器打开 http 链接时,请求的 header 都会有 User-Agent(UA、用户代理)信息。
\nUA 是一个特殊字符串头,服务器依次可以识别出客户使用的操作系统及版本、CPU 类型、浏览器及版本、浏览器渲染引擎、浏览器语言、浏览器插件等很多信息。
\n各渠道对应支付产品的名称不一样,一定要仔细看各支付产品的 API 介绍。
\n其本质均为在 APP 内置浏览器中实现 HTML5 支付。
\n\n文库的研发同学在这个思路上,做了优化迭代。动态生成一码付的二维码预先绑定用户所选的商品信息和价格,根据用户所选的商品动态更新。这样不仅支持一码多平台调起支付,而且不用用户选择商品输入金额,即可完成订单支付的功能,很丝滑。用户在真正扫码后,服务端才通过前端获取用户 UID,结合二维码绑定的商品信息,真正的生成订单,发送支付信息到第三方(qq、微信、支付宝),第三方生成支付订单推给用户设备,从而调起支付。
\n区别于固定的一码付,在文库的应用中,使用到了动态二维码,二维码本质是一个短网址,ID 服务提供短网址的唯一标志参数。唯一的短网址映射的 ID 绑定了商品的订单信息,技术和业务的深度结合,缩短了支付流程,提升用户的支付体验。
\n订单号在实际的业务过程中作为一个订单的唯一标识码存在,一般实现以下业务场景:
\n很多时候搜索订单相关信息的时候都是以订单 ID 作为唯一标识符,这是由于订单号的生成规则的唯一性决定的。从技术角度看,除了 ID 服务必要的特性之外,在订单号的设计上需要体现几个特性:
\n(1)信息安全
\n编号不能透露公司的运营情况,比如日销、公司流水号等信息,以及商业信息和用户手机号,身份证等隐私信息。并且不能有明显的整体规律(可以有局部规律),任意修改一个字符就能查询到另一个订单信息,这也是不允许的。
\n类比于我们高考时候的考生编号的生成规则,一定不能是连号的,否则只需要根据顺序往下查询就能搜索到别的考生的成绩,这是绝对不可允许。
\n(2)部分可读
\n位数要便于操作,因此要求订单号的位数适中,且局部有规律。这样可以方便在订单异常,或者退货时客服查询。
\n过长的订单号或易读性差的订单号会导致客服输入困难且易错率较高,影响用户体验的售后体验。因此在实际的业务场景中,订单号的设计通常都会适当携带一些允许公开的对使用场景有帮助的信息,如时间,星期,类型等等,这个主要根据所涉及的编号对应的使用场景来。
\n而且像时间、星期这些自增长的属于作为订单号的设计的一部分元素,有助于解决业务累积而导致的订单号重复的问题。
\n(3)查询效率
\n常见的电商平台订单号大多是纯数字组成,兼具可读性的同时,int 类型相对 varchar 类型的查询效率更高,对在线业务更加友好。
\n优惠券、兑换券是运营推广最常用的促销工具之一,合理使用它们,可以让买家得到实惠,商家提升商品销量。常见场景有:
\n从技术角度看,有些场景适合 ID 即时生成,比如电商平台购物领取的优惠券,只需要在用户领取时分配优惠券信息即可。有些线上线下结合的场景,比如疫情优惠券,瓶盖开奖,京东卡,超市卡这种,则需要预先生成,预先生成的券码具备以下特性:
\n1.预先生成,在活动正式开始前提供出来进行活动预热;
\n2.优惠券体量大,以万为单位,通常在 10 万级别以上;
\n3.不可破解、仿制券码;
\n4.支持用后核销;
\n5.优惠券、兑换券属于广撒网的策略,所以利用率低,也就不适合使用数据库进行存储 (占空间,有效的数据又少)。
\n设计思路上,需要设计一种有效的兑换码生成策略,支持预先生成,支持校验,内容简洁,生成的兑换码都具有唯一性,那么这种策略就是一种特殊的编解码策略,按照约定的编解码规则支撑上述需求。
\n既然是一种编解码规则,那么需要约定编码空间(也就是用户看到的组成兑换码的字符),编码空间由字符 a-z,A-Z,数字 0-9 组成,为了增强兑换码的可识别度,剔除大写字母 O 以及 I,可用字符如下所示,共 60 个字符:
\nabcdefghijklmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXZY0123456789
\n之前说过,兑换码要求近可能简洁,那么设计时就需要考虑兑换码的字符数,假设上限为 12 位,而字符空间有 60 位,那么可以表示的空间范围为 60^12=130606940160000000000000(也就是可以 12 位的兑换码可以生成天量,应该够运营同学挥霍了),转换成 2 进制:
\n1001000100000000101110011001101101110011000000000000000000000(61 位)
\n兑换码组成成分分析
\n兑换码可以预先生成,并且不需要额外的存储空间保存这些信息,每一个优惠方案都有独立的一组兑换码(指运营同学组织的每一场运营活动都有不同的兑换码,不能混合使用, 例如双 11 兑换码不能使用在双 12 活动上),每个兑换码有自己的编号,防止重复,为了保证兑换码的有效性,对兑换码的数据需要进行校验,当前兑换码的数据组成如下所示:
\n优惠方案 ID + 兑换码序列号 i + 校验码
\n编码方案
\n深耕业务还会有区分通用券和单独券的情况,分别具备以下特点,技术实现需要因地制宜地思考。
\n在分布式服务架构下,一个 Web 请求从网关流入,有可能会调用多个服务对请求进行处理,拿到最终结果。这个过程中每个服务之间的通信又是单独的网络请求,无论请求经过的哪个服务出了故障或者处理过慢都会对前端造成影响。
\n处理一个 Web 请求要调用的多个服务,为了能更方便的查询哪个环节的服务出现了问题,现在常用的解决方案是为整个系统引入分布式链路跟踪。
\n\n在分布式链路跟踪中有两个重要的概念:跟踪(trace)和 跨度( span)。trace 是请求在分布式系统中的整个链路视图,span 则代表整个链路中不同服务内部的视图,span 组合在一起就是整个 trace 的视图。
\n在整个请求的调用链中,请求会一直携带 traceid 往下游服务传递,每个服务内部也会生成自己的 spanid 用于生成自己的内部调用视图,并和 traceid 一起传递给下游服务。
\n这种场景下,生成的 ID 除了要求唯一之外,还要求生成的效率高、吞吐量大。traceid 需要具备接入层的服务器实例自主生成的能力,如果每个 trace 中的 ID 都需要请求公共的 ID 服务生成,纯纯的浪费网络带宽资源。且会阻塞用户请求向下游传递,响应耗时上升,增加了没必要的风险。所以需要服务器实例最好可以自行计算 tracid,spanid,避免依赖外部服务。
\n产生规则:服务器 IP + ID 产生的时间 + 自增序列 + 当前进程号 ,比如:
\n0ad1348f1403169275002100356696
\n前 8 位 0ad1348f 即产生 TraceId 的机器的 IP,这是一个十六进制的数字,每两位代表 IP 中的一段,我们把这个数字,按每两位转成 10 进制即可得到常见的 IP 地址表示方式 10.209.52.143,您也可以根据这个规律来查找到请求经过的第一个服务器。
\n后面的 13 位 1403169275002 是产生 TraceId 的时间。之后的 4 位 1003 是一个自增的序列,从 1000 涨到 9000,到达 9000 后回到 1000 再开始往上涨。最后的 5 位 56696 是当前的进程 ID,为了防止单机多进程出现 TraceId 冲突的情况,所以在 TraceId 末尾添加了当前的进程 ID。
\nspan 是层的意思,比如在第一个实例算是第一层, 请求代理或者分流到下一个实例处理,就是第二层,以此类推。通过层,SpanId 代表本次调用在整个调用链路树中的位置。
\n假设一个 服务器实例 A 接收了一次用户请求,代表是整个调用的根节点,那么 A 层处理这次请求产生的非服务调用日志记录 spanid 的值都是 0,A 层需要通过 RPC 依次调用 B、C、D 三个服务器实例,那么在 A 的日志中,SpanId 分别是 0.1,0.2 和 0.3,在 B、C、D 中,SpanId 也分别是 0.1,0.2 和 0.3;如果 C 系统在处理请求的时候又调用了 E,F 两个服务器实例,那么 C 系统中对应的 spanid 是 0.2.1 和 0.2.2,E、F 两个系统对应的日志也是 0.2.1 和 0.2.2。
\n根据上面的描述可以知道,如果把一次调用中所有的 SpanId 收集起来,可以组成一棵完整的链路树。
\nspanid 的生成本质:在跨层传递透传的同时,控制大小版本号的自增来实现的。
\n短网址主要功能包括网址缩短与还原两大功能。相对于长网址,短网址可以更方便地在电子邮件,社交网络,微博和手机上传播,例如原来很长的网址通过短网址服务即可生成相应的短网址,避免折行或超出字符限制。
\n\n常用的 ID 生成服务比如:MySQL ID 自增、 Redis 键自增、号段模式,生成的 ID 都是一串数字。短网址服务把客户的长网址转换成短网址,
\n实际是在 dwz.cn 域名后面拼接新产生的数字类型 ID,直接用数字 ID,网址长度也有些长,服务可以通过数字 ID 转更高进制的方式压缩长度。这种算法在短网址的技术实现上越来越多了起来,它可以进一步压缩网址长度。转进制的压缩算法在生活中有广泛的应用场景,举例:
\n\n\n本文重构完善自6000 字 | 16 图 | 深入理解 Spring Cloud Gateway 的原理 - 悟空聊架构这篇文章。
\n
Spring Cloud Gateway 属于 Spring Cloud 生态系统中的网关,其诞生的目标是为了替代老牌网关 Zuul。准确点来说,应该是 Zuul 1.x。Spring Cloud Gateway 起步要比 Zuul 2.x 更早。
\n为了提升网关的性能,Spring Cloud Gateway 基于 Spring WebFlux 。Spring WebFlux 使用 Reactor 库来实现响应式编程模型,底层基于 Netty 实现同步非阻塞的 I/O。
\n\nSpring Cloud Gateway 不仅提供统一的路由方式,并且基于 Filter 链的方式提供了网关基本的功能,例如:安全,监控/指标,限流。
\nSpring Cloud Gateway 和 Zuul 2.x 的差别不大,也是通过过滤器来处理请求。不过,目前更加推荐使用 Spring Cloud Gateway 而非 Zuul,Spring Cloud 生态对其支持更加友好。
\nSpring Cloud Gateway 的工作流程如下图所示:
\n\n这是 Spring 官方博客中的一张图,原文地址:https://spring.io/blog/2022/08/26/creating-a-custom-spring-cloud-gateway-filter。
\n具体的流程分析:
\n总结:客户端的请求先通过匹配规则找到合适的路由,就能映射到具体的服务。然后请求经过过滤器处理后转发给具体的服务,服务处理后,再次经过过滤器处理,最后返回给客户端。
\n断言(Predicate)这个词听起来极其深奥,它是一种编程术语,我们生活中根本就不会用它。说白了它就是对一个表达式进行 if 判断,结果为真或假,如果为真则做这件事,否则做那件事。
\n在 Gateway 中,如果客户端发送的请求满足了断言的条件,则映射到指定的路由器,就能转发到指定的服务上进行处理。
\n断言配置的示例如下,配置了两个路由规则,有一个 predicates 断言配置,当请求 url 中包含 api/thirdparty
,就匹配到了第一个路由 route_thirdparty
。
常见的路由断言规则如下图所示:
\n\nRoute 路由和 Predicate 断言的对应关系如下::
\n\n在使用 Spring Cloud Gateway 的时候,官方文档提供的方案总是基于配置文件或代码配置的方式。
\nSpring Cloud Gateway 作为微服务的入口,需要尽量避免重启,而现在配置更改需要重启服务不能满足实际生产过程中的动态刷新、实时变更的业务需求,所以我们需要在 Spring Cloud Gateway 运行时动态配置网关。
\n实现动态路由的方式有很多种,其中一种推荐的方式是基于 Nacos 注册中心来做。 Spring Cloud Gateway 可以从注册中心获取服务的元数据(例如服务名称、路径等),然后根据这些信息自动生成路由规则。这样,当你添加、移除或更新服务实例时,网关会自动感知并相应地调整路由规则,无需手动维护路由配置。
\n其实这些复杂的步骤并不需要我们手动实现,通过 Nacos Server 和 Spring Cloud Alibaba Nacos Config 即可实现配置的动态变更,官方文档地址:https://github.com/alibaba/spring-cloud-alibaba/wiki/Nacos-config 。
\n过滤器 Filter 按照请求和响应可以分为两种:
\n另外一种分类是按照过滤器 Filter 作用的范围进行划分:
\n常见的局部过滤器如下图所示:
\n\n具体怎么用呢?这里有个示例,如果 URL 匹配成功,则去掉 URL 中的 “api”。
\nfilters: #过滤器\n - RewritePath=/api/(?<segment>.*),/$\\{segment} # 将跳转路径中包含的 “api” 替换成空\n
当然我们也可以自定义过滤器,本篇不做展开。
\n常见的全局过滤器如下图所示:
\n\n全局过滤器最常见的用法是进行负载均衡。配置如下所示:
\nspring:\n cloud:\n gateway:\n routes:\n - id: route_member # 第三方微服务路由规则\n uri: lb://passjava-member # 负载均衡,将请求转发到注册中心注册的 passjava-member 服务\n predicates: # 断言\n - Path=/api/member/** # 如果前端请求路径包含 api/member,则应用这条路由规则\n filters: #过滤器\n - RewritePath=/api/(?<segment>.*),/$\\{segment} # 将跳转路径中包含的api替换成空\n
这里有个关键字 lb
,用到了全局过滤器 LoadBalancerClientFilter
,当匹配到这个路由后,会将请求转发到 passjava-member 服务,且支持负载均衡转发,也就是先将 passjava-member 解析成实际的微服务的 host 和 port,然后再转发给实际的微服务。
Spring Cloud Gateway 自带了限流过滤器,对应的接口是 RateLimiter
,RateLimiter
接口只有一个实现类 RedisRateLimiter
(基于 Redis + Lua 实现的限流),提供的限流功能比较简易且不易使用。
从 Sentinel 1.6.0 版本开始,Sentinel 引入了 Spring Cloud Gateway 的适配模块,可以提供两种资源维度的限流:route 维度和自定义 API 维度。也就是说,Spring Cloud Gateway 可以结合 Sentinel 实现更强大的网关流量控制。
\n在 SpringBoot 项目中,我们捕获全局异常只需要在项目中配置 @RestControllerAdvice
和 @ExceptionHandler
就可以了。不过,这种方式在 Spring Cloud Gateway 下不适用。
Spring Cloud Gateway 提供了多种全局处理的方式,比较常用的一种是实现ErrorWebExceptionHandler
并重写其中的handle
方法。
@Order(-1)\n@Component\n@RequiredArgsConstructor\npublic class GlobalErrorWebExceptionHandler implements ErrorWebExceptionHandler {\n private final ObjectMapper objectMapper;\n\n @Override\n public Mono<Void> handle(ServerWebExchange exchange, Throwable ex) {\n // ...\n }\n}\n
Disruptor 是一个相对冷门一些的知识点,不过,如果你的项目经历中用到了 Disruptor 的话,那面试中就很可能会被问到。
\n一位球友之前投稿的面经(社招)中就涉及一些 Disruptor 的问题,文章传送门:圆梦!顺利拿到字节、淘宝、拼多多等大厂 offer! 。
\n\n这篇文章可以看作是对 Disruptor 做的一个简单总结,每个问题都不会扯太深入,主要针对面试或者速览 Disruptor。
\nDisruptor 是一个开源的高性能内存队列,诞生初衷是为了解决内存队列的性能和内存安全问题,由英国外汇交易公司 LMAX 开发。
\n根据 Disruptor 官方介绍,基于 Disruptor 开发的系统 LMAX(新的零售金融交易平台),单线程就能支撑每秒 600 万订单。Martin Fowler 在 2011 年写的一篇文章 The LMAX Architecture 中专门介绍过这个 LMAX 系统的架构,感兴趣的可以看看这篇文章。。
\nLMAX 公司 2010 年在 QCon 演讲后,Disruptor 获得了业界关注,并获得了 2011 年的 Oracle 官方的 Duke's Choice Awards(Duke 选择大奖)。
\n\n\n\n“Duke 选择大奖”旨在表彰过去一年里全球个人或公司开发的、最具影响力的 Java 技术应用,由甲骨文公司主办。含金量非常高!
\n
我专门找到了 Oracle 官方当年颁布获得 Duke's Choice Awards 项目的那篇文章(文章地址:https://blogs.oracle.com/java/post/and-the-winners-arethe-dukes-choice-award) 。从文中可以看出,同年获得此大奖荣誉的还有大名鼎鼎的 Netty、JRebel 等项目。
\n\nDisruptor 提供的功能优点类似于 Kafka、RocketMQ 这类分布式队列,不过,其作为范围是 JVM(内存)。
\n关于如何在 Spring Boot 项目中使用 Disruptor,可以看这篇文章:Spring Boot + Disruptor 实战入门 。
\nDisruptor 主要解决了 JDK 内置线程安全队列的性能和内存安全问题。
\nJDK 中常见的线程安全的队列如下:
\n| 队列名字 | 锁 | 是否有界 |
\n|
NAT 协议(Network Address Translation) 的应用场景如同它的名称——网络地址转换,应用于内部网到外部网的地址转换过程中。具体地说,在一个小的子网(局域网,Local Area Network,LAN)内,各主机使用的是同一个 LAN 下的 IP 地址,但在该 LAN 以外,在广域网(Wide Area Network,WAN)中,需要一个统一的 IP 地址来标识该 LAN 在整个 Internet 上的位置。
\n这个场景其实不难理解。随着一个个小型办公室、家庭办公室(Small Office, Home Office, SOHO)的出现,为了管理这些 SOHO,一个个子网被设计出来,从而在整个 Internet 中的主机数量将非常庞大。如果每个主机都有一个“绝对唯一”的 IP 地址,那么 IPv4 地址的表达能力可能很快达到上限()。因此,实际上,SOHO 子网中的 IP 地址是“相对的”,这在一定程度上也缓解了 IPv4 地址的分配压力。
\nSOHO 子网的“代理人”,也就是和外界的窗口,通常由路由器扮演。路由器的 LAN 一侧管理着一个小子网,而它的 WAN 接口才是真正参与到 Internet 中的接口,也就有一个“绝对唯一的地址”。NAT 协议,正是在 LAN 中的主机在与 LAN 外界通信时,起到了地址转换的关键作用。
\n假设当前场景如上图。中间是一个路由器,它的右侧组织了一个 LAN,网络号为10.0.0/24
。LAN 侧接口的 IP 地址为10.0.0.4
,并且该子网内有至少三台主机,分别是10.0.0.1
,10.0.0.2
和10.0.0.3
。路由器的左侧连接的是 WAN,WAN 侧接口的 IP 地址为138.76.29.7
。
首先,针对以上信息,我们有如下事实需要说明:
\n10.0.0/24
,主机号为10.0.0/8
,三台主机地址,以及路由器的 LAN 侧接口地址,均由 DHCP 协议规定。而且,该 DHCP 运行在路由器内部(路由器自维护一个小 DHCP 服务器),从而为子网内提供 DHCP 服务。现在,路由器内部还运行着 NAT 协议,从而为 LAN-WAN 间通信提供地址转换服务。为此,一个很重要的结构是 NAT 转换表。为了说明 NAT 的运行细节,假设有以下请求发生:
\n10.0.0.1
向 IP 地址为128.119.40.186
的 Web 服务器(端口 80)发送了 HTTP 请求(如请求页面)。此时,主机10.0.0.1
将随机指派一个端口,如3345
,作为本次请求的源端口号,将该请求发送到路由器中(目的地址将是128.119.40.186
,但会先到达10.0.0.4
)。10.0.0.4
即路由器的 LAN 接口收到10.0.0.1
的请求。路由器将为该请求指派一个新的源端口号,如5001
,并将请求报文发送给 WAN 接口138.76.29.7
。同时,在 NAT 转换表中记录一条转换记录138.76.29.7:5001——10.0.0.1:3345。128.119.40.186
发送。之后,将会有如下响应发生:
\n128.119.40.186
收到请求,构造响应报文,并将其发送给目的地138.76.29.7:5001
。138.76.29.7:5001
在转换表中有记录,从而将其目的地址和目的端口转换成为10.0.0.1:3345
,再发送到10.0.0.4
上。10.0.0.1
。🐛 修正(参见:issue#2009):上图第四步的 Dest 值应该为 10.0.0.1:3345
而不是~~138.76.29.7:5001
~~,这里笔误了。
针对以上过程,有以下几个重点需要强调:
\n138.76.29.7:5001
的路由器转发的请求。因此,可以说,**路由器在 WAN 和 LAN 之间起到了屏蔽作用,**所有内部主机发送到外部的报文,都具有同一个 IP 地址(不同的端口号),所有外部发送到内部的报文,也都只有一个目的地(不同端口号),是经过了 NAT 转换后,外部报文才得以正确地送达内部主机。总结 NAT 协议的特点,有以下几点:
\n然而,NAT 协议由于其独特性,存在着一些争议。比如,可能你已经注意到了,**NAT 协议在 LAN 以外,标识一个内部主机时,使用的是端口号,因为 IP 地址都是相同的。**这种将端口号作为主机寻址的行为,可能会引发一些误会。此外,路由器作为网络层的设备,修改了传输层的分组内容(修改了源 IP 地址和端口号),同样是不规范的行为。但是,尽管如此,NAT 协议作为 IPv4 时代的产物,极大地方便了一些本来棘手的问题,一直被沿用至今。
\n\n", "image": "https://oss.javaguide.cn/github/javaguide/cs-basics/network/nat-demo.png", "date_published": "2023-04-30T08:44:12.000Z", "date_modified": "2023-10-26T22:44:02.000Z", "authors": [], "tags": [ "计算机基础" ] }, { "title": "程序员简历编写指南(重要)", "url": "https://javaguide.cn/interview-preparation/resume-guide.html", "id": "https://javaguide.cn/interview-preparation/resume-guide.html", "summary": " 友情提示 本文节选自 。这是一份教你如何更高效地准备面试的小册,涵盖常见八股文(系统设计、常见框架、分布式、高并发 ……)、优质面经等内容。 前言 一份好的简历可以在整个申请面试以及面试过程中起到非常重要的作用。 为什么说简历很重要呢? 我们可以从下面几点来说: 1、简历就像是我们的一个门面一样,它在很大程度上决定了是否能够获得面试机会。 假如你是网...", "content_html": "友情提示
\n本文节选自 《Java 面试指北》。这是一份教你如何更高效地准备面试的小册,涵盖常见八股文(系统设计、常见框架、分布式、高并发 ……)、优质面经等内容。
\n一份好的简历可以在整个申请面试以及面试过程中起到非常重要的作用。
\n为什么说简历很重要呢? 我们可以从下面几点来说:
\n1、简历就像是我们的一个门面一样,它在很大程度上决定了是否能够获得面试机会。
\n另外,就算你通过了第一轮的筛选获得面试机会,后面的面试中,面试官也会根据你的简历来判断你究竟是否值得他花费很多时间去面试。
\n2、简历上的内容很大程度上决定了面试官提问的侧重点。
\n在不夸大自己能力的情况下,写出一份好的简历也是一项很棒的能力。一般情况下,技术能力和学习能力比较厉害的,写出来的简历也比较棒!
\n简历的样式真的非常非常重要!!!如果你的简历样式丑到没朋友的话,面试官真的没有看下去的欲望。一天处理上百份的简历的痛苦,你不懂!
\n我这里的话,推荐大家使用 Markdown 语法写简历,然后再将 Markdown 格式转换为 PDF 格式后进行简历投递。如果你对 Markdown 语法不太了解的话,可以花半个小时简单看一下 Markdown 语法说明: http://www.markdown.cn/。
\n下面是我收集的一些还不错的简历模板:
\n上面这些简历模板大多是只有 1 页内容,很难展现足够的信息量。如果你不是顶级大牛(比如 ACM 大赛获奖)的话,我建议还是尽可能多写一点可以突出你自己能力的内容(校招生 2 页之内,社招生 3 页之内,记得精炼语言,不要过多废话)。
\n再总结几点 简历排版的注意事项:
\n另外,知识星球里还有真实的简历模板可供参考,地址:https://t.zsxq.com/12ypxGNzU (需加入知识星球获取)。
\n\n示例:
\n\n简历要不要放照片呢? 很多人写简历的时候都有这个问题。
\n其实放不放都行,影响不大,完全不用在意这个问题。除非,你投递的岗位明确要求要放照片。 不过,如果要放的话,不要放生活照,还是应该放正规一些的照片比如证件照。
\n你想要应聘什么岗位,希望在什么城市。另外,你也可以将求职意向放到个人信息这块写。
\n示例:
\n\n教育经历也不可或缺。通过教育经历的介绍,你要确保能让面试官就可以知道你的学历、专业、毕业学校以及毕业的日期。
\n示例:
\n\n\n北京理工大学 硕士,软件工程 2019.09 - 2022.01
\n
\n湖南大学 学士,应用化学 2015.09 ~ 2019.06
先问一下你自己会什么,然后看看你意向的公司需要什么。一般 HR 可能并不太懂技术,所以他在筛选简历的时候可能就盯着你专业技能的关键词来看。对于公司有要求而你不会的技能,你可以花几天时间学习一下,然后在简历上可以写上自己了解这个技能。
\n下面是一份最新的 Java 后端开发技能清单,你可以根据自身情况以及岗位招聘要求做动态调整,核心思想就是尽可能满足岗位招聘的所有技能要求。
\n\n我这里再单独放一个我看过的某位同学的技能介绍,我们来找找问题。
\n\n上图中的技能介绍存在的问题:
\n工作经历针对社招,实习经历针对校招。
\n工作经历建议采用时间倒序的方式来介绍。实习经历和工作经历都需要简单突出介绍自己在职期间主要做了什么。
\n示例:
\n\n\nXXX 公司 (201X 年 X 月 ~ 201X 年 X 月 )
\n\n
\n- 职位:Java 后端开发工程师
\n- 工作内容:主要负责 XXX
\n
简历上有一两个项目经历很正常,但是真正能把项目经历很好的展示给面试官的非常少。
\n很多求职者的项目经历介绍都会面临过于啰嗦、过于简单、没突出亮点等问题。
\n项目经历介绍模板如下:
\n\n\n项目名称(字号要大一些)
\n2017-05~2018-06 淘宝 Java 后端开发工程师
\n\n
\n- 项目描述 : 简单描述项目是做什么的。
\n- 技术栈 :用了什么技术(如 Spring Boot + MySQL + Redis + Mybatis-plus + Spring Security + Oauth2)
\n- 工作内容/个人职责 : 简单描述自己做了什么,解决了什么问题,带来了什么实质性的改善。突出自己的能力,不要过于平淡的叙述。
\n- 个人收获(可选) : 从这个项目中你学会了那些东西,使用到了那些技术,学会了那些新技术的使用。通常是可以不用写个人收获的,因为你在个人职责介绍中写的东西已经表明了自己的主要收获。
\n- 项目成果(可选) :简单描述这个项目取得了什么成绩。
\n
1、项目经历应该突出自己做了什么,简单概括项目基本情况。
\n项目介绍尽量压缩在两行之内,不需要介绍太多,但也不要随便几个字就介绍完了。
\n另外,个人收获和项目成果都是可选的,如果选择写的话,也不要花费太多篇幅,记住你的重点是介绍工作内容/个人职责。
\n2、技术架构直接写技术名词就行,不要再介绍技术是干嘛的了,没意义,属于无效介绍。
\n\n3、尽量减少纯业务的个人职责介绍,对于面试不太友好。尽量再多挖掘一些亮点(6~8 条个人职责介绍差不多了,做好筛选),最好可以体现自己的综合素质,比如你是如何协调项目组成员协同开发的或者在遇到某一个棘手的问题的时候你是如何解决的又或者说你在这个项目优化了某个模块的性能。
\n即使不是你做的功能模块或者解决的问题,你只要搞懂吃透了就能拿来自己用,适当润色即可!
\n像性能优化方向上的亮点面试之前也比较容易准备,但也不要都是性能优化相关的,这种也算是一个极端。
\n另外,技术优化取得的成果尽量要量化一下:
\n个人职责介绍示例 :
\n4、如果你觉得你的项目技术比较落后的话,可以自己私下进行改进。重要的是让项目比较有亮点,通过什么方式就无所谓了。
\n项目经历这部分对于简历来说非常重要,《Java 面试指北》的面试准备篇有好几篇关于优化项目经历的文章,建议你仔细阅读一下,应该会对你有帮助。
\n\n5、避免个人职责介绍都是围绕一个技术点来写,非常不可取。
\n\n6、避免模糊性描述,介绍要具体(技术+场景+效果),也要注意精简语言(避免堆砌技术词,省略不必要的描述)。
\n\n如果你有含金量比较高的竞赛(比如 ACM、阿里的天池大赛)的获奖经历的话,荣誉奖项这块内容一定要写一下!并且,你还可以将荣誉奖项这块内容适当往前放,放在一个更加显眼的位置。
\n如果有比较亮眼的校园经历的话就简单写一下,没有就不写!
\n个人评价就是对自己的解读,一定要用简洁的语言突出自己的特点和优势,避免废话! 像勤奋、吃苦这些比较虚的东西就不要扯了,面试官看着这种个人评价就烦。
\n我们可以从下面几个角度来写个人评价:
\n列举 3 个实际的例子:
\n相信大家一定听说过 STAR 法则。对于面试,你可以将这个法则用在自己的简历以及和面试官沟通交流的过程中。
\nSTAR 法则由下面 4 个单词组成(STAR 法则的名字就是由它们的首字母组成):
\n除了 STAR 法则,你还需要了解在销售行业经常用到的一个叫做 FAB 的法则。
\nFAB 法则由下面 3 个单词组成(FAB 法则的名字就是由它们的首字母组成):
\n简单来说,FAB 法则主要是让你的面试官知道你的优势和你能为公司带来的价值。
\n精简表述,突出亮点。校招简历建议不要超过 2 页,社招简历建议不要超过 3 页。如果内容过多的话,不需要非把内容压缩到一页,保持排版干净整洁就可以了。
\n看了几千份简历,有少部分同学的简历页数都接近 10 页了,让我头皮发麻。
\n\n尽量避免主观表述,少一点语义模糊的形容词。表述要简洁明了,简历结构要清晰。
\n举例:
\n简历样式同样很重要,一定要注意!不必追求花里胡哨,但要尽量保证结构清晰且易于阅读。
\n到目前为止,我至少帮助 6000+ 位球友提供了免费的简历修改服务。由于个人精力有限,修改简历仅限加入星球的读者,需要帮看简历的话,可以加入 JavaGuide 官方知识星球(点击链接查看详细介绍)。
\n\n虽然收费只有培训班/训练营的百分之一,但是知识星球里的内容质量更高,提供的服务也更全面,非常适合准备 Java 面试和学习 Java 的同学。
\n下面是星球提供的部分服务(点击下方图片即可获取知识星球的详细介绍):
\n\n这里再提供一份限时专属优惠卷:
\n\n", "image": "https://oss.javaguide.cn/javamianshizhibei/image-20230918073550606.png", "date_published": "2023-04-28T13:38:12.000Z", "date_modified": "2023-10-10T03:03:34.000Z", "authors": [], "tags": [ "面试准备" ] }, { "title": "降级&熔断详解(付费)", "url": "https://javaguide.cn/high-availability/fallback-and-circuit-breaker.html", "id": "https://javaguide.cn/high-availability/fallback-and-circuit-breaker.html", "summary": "降级&熔断 相关的面试题为我的知识星球(点击链接即可查看详细介绍以及加入方法)专属内容,已经整理到了《Java 面试指北》中。 (点击链接即可查看详细介绍)的部分内容展示如下,你可以将其看作是 JavaGuide 的补充完善,两者可以配合使用。 《Java 面试指北》内容概览《Java 面试指北》内容概览 为了帮助更多同学准备 Java 面试以及学习 ...", "content_html": "降级&熔断 相关的面试题为我的知识星球(点击链接即可查看详细介绍以及加入方法)专属内容,已经整理到了《Java 面试指北》中。
\n《Java 面试指北》(点击链接即可查看详细介绍)的部分内容展示如下,你可以将其看作是 JavaGuide 的补充完善,两者可以配合使用。
\n\n为了帮助更多同学准备 Java 面试以及学习 Java ,我创建了一个纯粹的Java 面试知识星球。虽然收费只有培训班/训练营的百分之一,但是知识星球里的内容质量更高,提供的服务也更全面,非常适合准备 Java 面试和学习 Java 的同学。
\n欢迎准备 Java 面试以及学习 Java 的同学加入我的 知识星球,干货非常多,学习氛围也很不错!收费虽然是白菜价,但星球里的内容或许比你参加上万的培训班质量还要高。
\n下面是星球提供的部分服务(点击下方图片即可获取知识星球的详细介绍):
\n\n我有自己的原则,不割韭菜,用心做内容,真心希望帮助到你!
\n如果你感兴趣的话,不妨花 3 分钟左右看看星球的详细介绍:JavaGuide 知识星球详细介绍 。
\n这里再送一个 30 元的星球专属优惠券,数量有限(价格即将上调。老用户续费半价 ,微信扫码即可续费)!
\n\n进入星球之后,记得查看 星球使用指南 (一定要看!!!) 和 星球优质主题汇总 。
\n无任何套路,无任何潜在收费项。用心做内容,不割韭菜!
\n不过, 一定要确定需要再进 。并且, 三天之内觉得内容不满意可以全额退款 。
\n\n", "date_published": "2023-04-28T12:32:33.000Z", "date_modified": "2024-01-13T15:26:26.000Z", "authors": [], "tags": [ "高可用" ] }, { "title": "HTTP vs HTTPS(应用层)", "url": "https://javaguide.cn/cs-basics/network/http-vs-https.html", "id": "https://javaguide.cn/cs-basics/network/http-vs-https.html", "summary": "HTTP 协议 HTTP 协议介绍 HTTP 协议,全称超文本传输协议(Hypertext Transfer Protocol)。顾名思义,HTTP 协议就是用来规范超文本的传输,超文本,也就是网络上的包括文本在内的各式各样的消息,具体来说,主要是来规范浏览器和服务器端的行为的。 并且,HTTP 是一个无状态(stateless)协议,也就是说服务器不...", "content_html": "HTTP 协议,全称超文本传输协议(Hypertext Transfer Protocol)。顾名思义,HTTP 协议就是用来规范超文本的传输,超文本,也就是网络上的包括文本在内的各式各样的消息,具体来说,主要是来规范浏览器和服务器端的行为的。
\n并且,HTTP 是一个无状态(stateless)协议,也就是说服务器不维护任何有关客户端过去所发请求的消息。这其实是一种懒政,有状态协议会更加复杂,需要维护状态(历史信息),而且如果客户或服务器失效,会产生状态的不一致,解决这种不一致的代价更高。
\nHTTP 是应用层协议,它以 TCP(传输层)作为底层协议,默认端口为 80. 通信过程主要如下:
\n扩展性强、速度快、跨平台支持性好。
\nHTTPS 协议(Hyper Text Transfer Protocol Secure),是 HTTP 的加强安全版本。HTTPS 是基于 HTTP 的,也是用 TCP 作为底层协议,并额外使用 SSL/TLS 协议用作加密和安全认证。默认端口号是 443.
\nHTTPS 协议中,SSL 通道通常使用基于密钥的加密算法,密钥长度通常是 40 比特或 128 比特。
\n保密性好、信任度高。
\nHTTPS 之所以能达到较高的安全性要求,就是结合了 SSL/TLS 和 TCP 协议,对通信数据进行加密,解决了 HTTP 数据透明的问题。接下来重点介绍一下 SSL/TLS 的工作原理。
\nSSL 和 TLS 没有太大的区别。
\nSSL 指安全套接字协议(Secure Sockets Layer),首次发布与 1996 年。SSL 的首次发布其实已经是他的 3.0 版本,SSL 1.0 从未面世,SSL 2.0 则具有较大的缺陷(DROWN 缺陷——Decrypting RSA with Obsolete and Weakened eNcryption)。很快,在 1999 年,SSL 3.0 进一步升级,新版本被命名为 TLS 1.0。因此,TLS 是基于 SSL 之上的,但由于习惯叫法,通常把 HTTPS 中的核心加密协议混称为 SSL/TLS。
\nSSL/TLS 的核心要素是非对称加密。非对称加密采用两个密钥——一个公钥,一个私钥。在通信时,私钥仅由解密者保存,公钥由任何一个想与解密者通信的发送者(加密者)所知。可以设想一个场景,
\n\n\n\n在某个自助邮局,每个通信信道都是一个邮箱,每一个邮箱所有者都在旁边立了一个牌子,上面挂着一把钥匙:这是我的公钥,发送者请将信件放入我的邮箱,并用公钥锁好。
\n但是公钥只能加锁,并不能解锁。解锁只能由邮箱的所有者——因为只有他保存着私钥。
\n这样,通信信息就不会被其他人截获了,这依赖于私钥的保密性。
\n
非对称加密的公钥和私钥需要采用一种复杂的数学机制生成(密码学认为,为了较高的安全性,尽量不要自己创造加密方案)。公私钥对的生成算法依赖于单向陷门函数。
\n\n\n\n单向函数:已知单向函数 f,给定任意一个输入 x,易计算输出 y=f(x);而给定一个输出 y,假设存在 f(x)=y,很难根据 f 来计算出 x。
\n单向陷门函数:一个较弱的单向函数。已知单向陷门函数 f,陷门 h,给定任意一个输入 x,易计算出输出 y=f(x;h);而给定一个输出 y,假设存在 f(x;h)=y,很难根据 f 来计算出 x,但可以根据 f 和 h 来推导出 x。
\n
上图就是一个单向函数(不是单项陷门函数),假设有一个绝世秘籍,任何知道了这个秘籍的人都可以把苹果汁榨成苹果,那么这个秘籍就是“陷门”了吧。
\n在这里,函数 f 的计算方法相当于公钥,陷门 h 相当于私钥。公钥 f 是公开的,任何人对已有输入,都可以用 f 加密,而要想根据加密信息还原出原信息,必须要有私钥才行。
\n使用 SSL/TLS 进行通信的双方需要使用非对称加密方案来通信,但是非对称加密设计了较为复杂的数学算法,在实际通信过程中,计算的代价较高,效率太低,因此,SSL/TLS 实际对消息的加密使用的是对称加密。
\n\n\n\n对称加密:通信双方共享唯一密钥 k,加解密算法已知,加密方利用密钥 k 加密,解密方利用密钥 k 解密,保密性依赖于密钥 k 的保密性。
\n
对称加密的密钥生成代价比公私钥对的生成代价低得多,那么有的人会问了,为什么 SSL/TLS 还需要使用非对称加密呢?因为对称加密的保密性完全依赖于密钥的保密性。在双方通信之前,需要商量一个用于对称加密的密钥。我们知道网络通信的信道是不安全的,传输报文对任何人是可见的,密钥的交换肯定不能直接在网络信道中传输。因此,使用非对称加密,对对称加密的密钥进行加密,保护该密钥不在网络信道中被窃听。这样,通信双方只需要一次非对称加密,交换对称加密的密钥,在之后的信息通信中,使用绝对安全的密钥,对信息进行对称加密,即可保证传输消息的保密性。
\nSSL/TLS 介绍到这里,了解信息安全的朋友又会想到一个安全隐患,设想一个下面的场景:
\n\n\n\n客户端 C 和服务器 S 想要使用 SSL/TLS 通信,由上述 SSL/TLS 通信原理,C 需要先知道 S 的公钥,而 S 公钥的唯一获取途径,就是把 S 公钥在网络信道中传输。要注意网络信道通信中有几个前提:
\n\n
\n- 任何人都可以捕获通信包
\n- 通信包的保密性由发送者设计
\n- 保密算法设计方案默认为公开,而(解密)密钥默认是安全的
\n因此,假设 S 公钥不做加密,在信道中传输,那么很有可能存在一个攻击者 A,发送给 C 一个诈包,假装是 S 公钥,其实是诱饵服务器 AS 的公钥。当 C 收获了 AS 的公钥(却以为是 S 的公钥),C 后续就会使用 AS 公钥对数据进行加密,并在公开信道传输,那么 A 将捕获这些加密包,用 AS 的私钥解密,就截获了 C 本要给 S 发送的内容,而 C 和 S 二人全然不知。
\n同样的,S 公钥即使做加密,也难以避免这种信任性问题,C 被 AS 拐跑了!
\n
为了公钥传输的信赖性问题,第三方机构应运而生——证书颁发机构(CA,Certificate Authority)。CA 默认是受信任的第三方。CA 会给各个服务器颁发证书,证书存储在服务器上,并附有 CA 的电子签名(见下节)。
\n当客户端(浏览器)向服务器发送 HTTPS 请求时,一定要先获取目标服务器的证书,并根据证书上的信息,检验证书的合法性。一旦客户端检测到证书非法,就会发生错误。客户端获取了服务器的证书后,由于证书的信任性是由第三方信赖机构认证的,而证书上又包含着服务器的公钥信息,客户端就可以放心的信任证书上的公钥就是目标服务器的公钥。
\n好,到这一小节,已经是 SSL/TLS 的尾声了。上一小节提到了数字签名,数字签名要解决的问题,是防止证书被伪造。第三方信赖机构 CA 之所以能被信赖,就是 靠数字签名技术 。
\n数字签名,是 CA 在给服务器颁发证书时,使用散列+加密的组合技术,在证书上盖个章,以此来提供验伪的功能。具体行为如下:
\n\n\n\nCA 知道服务器的公钥,对证书采用散列技术生成一个摘要。CA 使用 CA 私钥对该摘要进行加密,并附在证书下方,发送给服务器。
\n现在服务器将该证书发送给客户端,客户端需要验证该证书的身份。客户端找到第三方机构 CA,获知 CA 的公钥,并用 CA 公钥对证书的签名进行解密,获得了 CA 生成的摘要。
\n客户端对证书数据(包含服务器的公钥)做相同的散列处理,得到摘要,并将该摘要与之前从签名中解码出的摘要做对比,如果相同,则身份验证成功;否则验证失败。
\n
总结来说,带有证书的公钥传输机制如下:
\n对于数字签名,我这里讲的比较简单,如果你没有搞清楚的话,强烈推荐你看看数字签名及数字证书原理这个视频,这是我看过最清晰的讲解。
\n\nhttp://
,HTTPS 的 URL 前缀是 https://
。这篇文章会从下面几个维度来对比 HTTP 1.0 和 HTTP 1.1:
\nHTTP/1.0 仅定义了 16 种状态码。HTTP/1.1 中新加入了大量的状态码,光是错误响应状态码就新增了 24 种。比如说,100 (Continue)
——在请求大资源前的预热请求,206 (Partial Content)
——范围请求的标识码,409 (Conflict)
——请求与当前资源的规定冲突,410 (Gone)
——资源已被永久转移,而且没有任何已知的转发地址。
缓存技术通过避免用户与源服务器的频繁交互,节约了大量的网络带宽,降低了用户接收信息的延迟。
\nHTTP/1.0 提供的缓存机制非常简单。服务器端使用Expires
标签来标志(时间)一个响应体,在Expires
标志时间内的请求,都会获得该响应体缓存。服务器端在初次返回给客户端的响应体中,有一个Last-Modified
标签,该标签标记了被请求资源在服务器端的最后一次修改。在请求头中,使用If-Modified-Since
标签,该标签标志一个时间,意为客户端向服务器进行问询:“该时间之后,我要请求的资源是否有被修改过?”通常情况下,请求头中的If-Modified-Since
的值即为上一次获得该资源时,响应体中的Last-Modified
的值。
如果服务器接收到了请求头,并判断If-Modified-Since
时间后,资源确实没有修改过,则返回给客户端一个304 not modified
响应头,表示”缓冲可用,你从浏览器里拿吧!”。
如果服务器判断If-Modified-Since
时间后,资源被修改过,则返回给客户端一个200 OK
的响应体,并附带全新的资源内容,表示”你要的我已经改过的,给你一份新的”。
HTTP/1.1 的缓存机制在 HTTP/1.0 的基础上,大大增加了灵活性和扩展性。基本工作原理和 HTTP/1.0 保持不变,而是增加了更多细致的特性。其中,请求头中最常见的特性就是Cache-Control
,详见 MDN Web 文档 Cache-Control.
HTTP/1.0 默认使用短连接 ,也就是说,客户端和服务器每进行一次 HTTP 操作,就建立一次连接,任务结束就中断连接。当客户端浏览器访问的某个 HTML 或其他类型的 Web 页中包含有其他的 Web 资源(如 JavaScript 文件、图像文件、CSS 文件等),每遇到这样一个 Web 资源,浏览器就会重新建立一个 TCP 连接,这样就会导致有大量的“握手报文”和“挥手报文”占用了带宽。
\n为了解决 HTTP/1.0 存在的资源浪费的问题, HTTP/1.1 优化为默认长连接模式 。 采用长连接模式的请求报文会通知服务端:“我向你请求连接,并且连接成功建立后,请不要关闭”。因此,该 TCP 连接将持续打开,为后续的客户端-服务端的数据交互服务。也就是说在使用长连接的情况下,当一个网页打开完成后,客户端和服务器之间用于传输 HTTP 数据的 TCP 连接不会关闭,客户端再次访问这个服务器时,会继续使用这一条已经建立的连接。
\n如果 TCP 连接一直保持的话也是对资源的浪费,因此,一些服务器软件(如 Apache)还会支持超时时间的时间。在超时时间之内没有新的请求达到,TCP 连接才会被关闭。
\n有必要说明的是,HTTP/1.0 仍提供了长连接选项,即在请求头中加入Connection: Keep-alive
。同样的,在 HTTP/1.1 中,如果不希望使用长连接选项,也可以在请求头中加入Connection: close
,这样会通知服务器端:“我不需要长连接,连接成功后即可关闭”。
HTTP 协议的长连接和短连接,实质上是 TCP 协议的长连接和短连接。
\n实现长连接需要客户端和服务端都支持长连接。
\n域名系统(DNS)允许多个主机名绑定到同一个 IP 地址上,但是 HTTP/1.0 并没有考虑这个问题,假设我们有一个资源 URL 是http://example1.org/home.html,HTTP/1.0 的请求报文中,将会请求的是GET /home.html HTTP/1.0
.也就是不会加入主机名。这样的报文送到服务器端,服务器是理解不了客户端想请求的真正网址。
因此,HTTP/1.1 在请求头中加入了Host
字段。加入Host
字段的报文头部将会是:
GET /home.html HTTP/1.1\nHost: example1.org\n
这样,服务器端就可以确定客户端想要请求的真正的网址了。
\nHTTP/1.1 引入了范围请求(range request)机制,以避免带宽的浪费。当客户端想请求一个文件的一部分,或者需要继续下载一个已经下载了部分但被终止的文件,HTTP/1.1 可以在请求中加入Range
头部,以请求(并只能请求字节型数据)数据的一部分。服务器端可以忽略Range
头部,也可以返回若干Range
响应。
如果一个响应包含部分数据的话,那么将带有206 (Partial Content)
状态码。该状态码的意义在于避免了 HTTP/1.0 代理缓存错误地把该响应认为是一个完整的数据响应,从而把他当作为一个请求的响应缓存。
在范围响应中,Content-Range
头部标志指示出了该数据块的偏移量和数据块的长度。
HTTP/1.1 中新加入了状态码100
。该状态码的使用场景为,存在某些较大的文件请求,服务器可能不愿意响应这种请求,此时状态码100
可以作为指示请求是否会被正常响应,过程如下图:
然而在 HTTP/1.0 中,并没有100 (Continue)
状态码,要想触发这一机制,可以发送一个Expect
头部,其中包含一个100-continue
的值。
许多格式的数据在传输时都会做预压缩处理。数据的压缩可以大幅优化带宽的利用。然而,HTTP/1.0 对数据压缩的选项提供的不多,不支持压缩细节的选择,也无法区分端到端(end-to-end)压缩或者是逐跳(hop-by-hop)压缩。
\nHTTP/1.1 则对内容编码(content-codings)和传输编码(transfer-codings)做了区分。内容编码总是端到端的,传输编码总是逐跳的。
\nHTTP/1.0 包含了Content-Encoding
头部,对消息进行端到端编码。HTTP/1.1 加入了Transfer-Encoding
头部,可以对消息进行逐跳传输编码。HTTP/1.1 还加入了Accept-Encoding
头部,是客户端用来指示他能处理什么样的内容编码。
100 (Continue)
——在请求大资源前的预热请求,206 (Partial Content)
——范围请求的标识码,409 (Conflict)
——请求与当前资源的规定冲突,410 (Gone)
——资源已被永久转移,而且没有任何已知的转发地址。Host
字段。Key differences between HTTP/1.0 and HTTP/1.1
\n\n", "date_published": "2023-04-28T09:15:47.000Z", "date_modified": "2023-12-30T09:14:13.000Z", "authors": [], "tags": [ "计算机基础" ] }, { "title": "OSI 和 TCP/IP 网络分层模型详解(基础)", "url": "https://javaguide.cn/cs-basics/network/osi-and-tcp-ip-model.html", "id": "https://javaguide.cn/cs-basics/network/osi-and-tcp-ip-model.html", "summary": "OSI 七层模型 OSI 七层模型 是国际标准化组织提出一个网络分层模型,其大体结构以及每一层提供的功能如下图所示: OSI 七层模型OSI 七层模型 每一层都专注做一件事情,并且每一层都需要使用下一层提供的功能比如传输层需要使用网络层提供的路由和寻址功能,这样传输层才知道把数据传输到哪里去。 OSI 的七层体系结构概念清楚,理论也很完整,但是它比较复...", "content_html": "OSI 七层模型 是国际标准化组织提出一个网络分层模型,其大体结构以及每一层提供的功能如下图所示:
\n\n每一层都专注做一件事情,并且每一层都需要使用下一层提供的功能比如传输层需要使用网络层提供的路由和寻址功能,这样传输层才知道把数据传输到哪里去。
\nOSI 的七层体系结构概念清楚,理论也很完整,但是它比较复杂而且不实用,而且有些功能在多个层中重复出现。
\n上面这种图可能比较抽象,再来一个比较生动的图片。下面这个图片是我在国外的一个网站上看到的,非常赞!
\n\n既然 OSI 七层模型这么厉害,为什么干不过 TCP/IP 四 层模型呢?
\n的确,OSI 七层模型当时一直被一些大公司甚至一些国家政府支持。这样的背景下,为什么会失败呢?我觉得主要有下面几方面原因:
\nOSI 七层模型虽然失败了,但是却提供了很多不错的理论基础。为了更好地去了解网络分层,OSI 七层模型还是非常有必要学习的。
\n最后再分享一个关于 OSI 七层模型非常不错的总结图片!
\n\nTCP/IP 四层模型 是目前被广泛采用的一种模型,我们可以将 TCP / IP 模型看作是 OSI 七层模型的精简版本,由以下 4 层组成:
\n需要注意的是,我们并不能将 TCP/IP 四层模型 和 OSI 七层模型完全精确地匹配起来,不过可以简单将两者对应起来,如下图所示:
\n\n应用层位于传输层之上,主要提供两个终端设备上的应用程序之间信息交换的服务,它定义了信息交换的格式,消息会交给下一层传输层来传输。 我们把应用层交互的数据单元称为报文。
\n\n应用层协议定义了网络通信规则,对于不同的网络应用需要不同的应用层协议。在互联网中应用层协议很多,如支持 Web 应用的 HTTP 协议,支持电子邮件的 SMTP 协议等等。
\n应用层常见协议:
\n\n关于这些协议的详细介绍请看 应用层常见协议总结(应用层) 这篇文章。
\n传输层的主要任务就是负责向两台终端设备进程之间的通信提供通用的数据传输服务。 应用进程利用该服务传送应用层报文。“通用的”是指并不针对某一个特定的网络应用,而是多种应用可以使用同一个运输层服务。
\n传输层常见协议:
\n\n网络层负责为分组交换网上的不同主机提供通信服务。 在发送数据时,网络层把运输层产生的报文段或用户数据报封装成分组和包进行传送。在 TCP/IP 体系结构中,由于网络层使用 IP 协议,因此分组也叫 IP 数据报,简称数据报。
\n⚠️ 注意:不要把运输层的“用户数据报 UDP”和网络层的“IP 数据报”弄混。
\n网络层的还有一个任务就是选择合适的路由,使源主机运输层所传下来的分组,能通过网络层中的路由器找到目的主机。
\n这里强调指出,网络层中的“网络”二字已经不是我们通常谈到的具体网络,而是指计算机网络体系结构模型中第三层的名称。
\n互联网是由大量的异构(heterogeneous)网络通过路由器(router)相互连接起来的。互联网使用的网络层协议是无连接的网际协议(Internet Protocol)和许多路由选择协议,因此互联网的网络层也叫做 网际层 或 IP 层。
\n网络层常见协议:
\n\n我们可以把网络接口层看作是数据链路层和物理层的合体。
\n网络接口层重要功能和协议如下图所示:
\n\n简单总结一下每一层包含的协议和核心技术:
\n\n应用层协议 :
\n传输层协议 :
\n网络层协议 :
\n网络接口层 :
\n在这篇文章的最后,我想聊聊:“为什么网络要分层?”。
\n说到分层,我们先从我们平时使用框架开发一个后台程序来说,我们往往会按照每一层做不同的事情的原则将系统分为三层(复杂的系统分层会更多):
\n复杂的系统需要分层,因为每一层都需要专注于一类事情。网络分层的原因也是一样,每一层只专注于做一类事情。
\n好了,再来说回:“为什么网络要分层?”。我觉得主要有 3 方面的原因:
\n我想到了计算机世界非常非常有名的一句话,这里分享一下:
\n\n\n计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决,计算机整个体系从上到下都是按照严格的层次结构设计的。
\n
经历过技术面试的小伙伴想必对 CAP & BASE 这个两个理论已经再熟悉不过了!
\n我当年参加面试的时候,不夸张地说,只要问到分布式相关的内容,面试官几乎是必定会问这两个分布式相关的理论。一是因为这两个分布式基础理论是学习分布式知识的必备前置基础,二是因为很多面试官自己比较熟悉这两个理论(方便提问)。
\n我们非常有必要将这两个理论搞懂,并且能够用自己的理解给别人讲出来。
\nCAP 理论/定理起源于 2000 年,由加州大学伯克利分校的 Eric Brewer 教授在分布式计算原理研讨会(PODC)上提出,因此 CAP 定理又被称作 布鲁尔定理(Brewer’s theorem)
\n2 年后,麻省理工学院的 Seth Gilbert 和 Nancy Lynch 发表了布鲁尔猜想的证明,CAP 理论正式成为分布式领域的定理。
\nCAP 也就是 Consistency(一致性)、Availability(可用性)、Partition Tolerance(分区容错性) 这三个单词首字母组合。
\n\nCAP 理论的提出者布鲁尔在提出 CAP 猜想的时候,并没有详细定义 Consistency、Availability、Partition Tolerance 三个单词的明确定义。
\n因此,对于 CAP 的民间解读有很多,一般比较被大家推荐的是下面 👇 这种版本的解读。
\n在理论计算机科学中,CAP 定理(CAP theorem)指出对于一个分布式系统来说,当设计读写操作时,只能同时满足以下三点中的两个:
\n什么是网络分区?
\n分布式系统中,多个节点之间的网络本来是连通的,但是因为某些故障(比如部分节点网络出了问题)某些节点之间不连通了,整个网络就分成了几块区域,这就叫 网络分区。
\n\n大部分人解释这一定律时,常常简单的表述为:“一致性、可用性、分区容忍性三者你只能同时达到其中两个,不可能同时达到”。实际上这是一个非常具有误导性质的说法,而且在 CAP 理论诞生 12 年之后,CAP 之父也在 2012 年重写了之前的论文。
\n\n\n当发生网络分区的时候,如果我们要继续服务,那么强一致性和可用性只能 2 选 1。也就是说当网络分区之后 P 是前提,决定了 P 之后才有 C 和 A 的选择。也就是说分区容错性(Partition tolerance)我们是必须要实现的。
\n简而言之就是:CAP 理论中分区容错性 P 是一定要满足的,在此基础上,只能满足可用性 A 或者一致性 C。
\n
因此,分布式系统理论上不可能选择 CA 架构,只能选择 CP 或者 AP 架构。 比如 ZooKeeper、HBase 就是 CP 架构,Cassandra、Eureka 就是 AP 架构,Nacos 不仅支持 CP 架构也支持 AP 架构。
\n为啥不可能选择 CA 架构呢? 举个例子:若系统出现“分区”,系统中的某个节点在进行写操作。为了保证 C, 必须要禁止其他节点的读写操作,这就和 A 发生冲突了。如果为了保证 A,其他节点的读写操作正常的话,那就和 C 发生冲突了。
\n选择 CP 还是 AP 的关键在于当前的业务场景,没有定论,比如对于需要确保强一致性的场景如银行一般会选择保证 CP 。
\n另外,需要补充说明的一点是:如果网络分区正常的话(系统在绝大部分时候所处的状态),也就说不需要保证 P 的时候,C 和 A 能够同时保证。
\n我这里以注册中心来探讨一下 CAP 的实际应用。考虑到很多小伙伴不知道注册中心是干嘛的,这里简单以 Dubbo 为例说一说。
\n下图是 Dubbo 的架构图。注册中心 Registry 在其中扮演了什么角色呢?提供了什么服务呢?
\n注册中心负责服务地址的注册与查找,相当于目录服务,服务提供者和消费者只在启动时与注册中心交互,注册中心不转发请求,压力较小。
\n\n常见的可以作为注册中心的组件有:ZooKeeper、Eureka、Nacos...。
\n🐛 修正(参见:issue#1906):
\nZooKeeper 通过可线性化(Linearizable)写入、全局 FIFO 顺序访问等机制来保障数据一致性。多节点部署的情况下, ZooKeeper 集群处于 Quorum 模式。Quorum 模式下的 ZooKeeper 集群, 是一组 ZooKeeper 服务器节点组成的集合,其中大多数节点必须同意任何变更才能被视为有效。
\n由于 Quorum 模式下的读请求不会触发各个 ZooKeeper 节点之间的数据同步,因此在某些情况下还是可能会存在读取到旧数据的情况,导致不同的客户端视图上看到的结果不同,这可能是由于网络延迟、丢包、重传等原因造成的。ZooKeeper 为了解决这个问题,提供了 Watcher 机制和版本号机制来帮助客户端检测数据的变化和版本号的变更,以保证数据的一致性。
\n在进行分布式系统设计和开发时,我们不应该仅仅局限在 CAP 问题上,还要关注系统的扩展性、可用性等等
\n在系统发生“分区”的情况下,CAP 理论只能满足 CP 或者 AP。要注意的是,这里的前提是系统发生了“分区”
\n如果系统没有发生“分区”的话,节点间的网络连接通信正常的话,也就不存在 P 了。这个时候,我们就可以同时保证 C 和 A 了。
\n总结:如果系统发生“分区”,我们要考虑选择 CP 还是 AP。如果系统没有发生“分区”的话,我们要思考如何保证 CA 。
\nBASE 理论起源于 2008 年, 由 eBay 的架构师 Dan Pritchett 在 ACM 上发表。
\nBASE 是 Basically Available(基本可用)、Soft-state(软状态) 和 Eventually Consistent(最终一致性) 三个短语的缩写。BASE 理论是对 CAP 中一致性 C 和可用性 A 权衡的结果,其来源于对大规模互联网系统分布式实践的总结,是基于 CAP 定理逐步演化而来的,它大大降低了我们对系统的要求。
\n即使无法做到强一致性,但每个应用都可以根据自身业务特点,采用适当的方式来使系统达到最终一致性。
\n\n\n也就是牺牲数据的一致性来满足系统的高可用性,系统中一部分数据不可用或者不一致时,仍需要保持系统整体“主要可用”。
\n
BASE 理论本质上是对 CAP 的延伸和补充,更具体地说,是对 CAP 中 AP 方案的一个补充。
\n为什么这样说呢?
\nCAP 理论这节我们也说过了:
\n\n\n如果系统没有发生“分区”的话,节点间的网络连接通信正常的话,也就不存在 P 了。这个时候,我们就可以同时保证 C 和 A 了。因此,如果系统发生“分区”,我们要考虑选择 CP 还是 AP。如果系统没有发生“分区”的话,我们要思考如何保证 CA 。
\n
因此,AP 方案只是在系统发生分区的时候放弃一致性,而不是永远放弃一致性。在分区故障恢复后,系统应该达到最终一致性。这一点其实就是 BASE 理论延伸的地方。
\n基本可用是指分布式系统在出现不可预知故障的时候,允许损失部分可用性。但是,这绝不等价于系统不可用。
\n什么叫允许损失部分可用性呢?
\n软状态指允许系统中的数据存在中间状态(CAP 理论中的数据不一致),并认为该中间状态的存在不会影响系统的整体可用性,即允许系统在不同节点的数据副本之间进行数据同步的过程存在延时。
\n最终一致性强调的是系统中所有的数据副本,在经过一段时间的同步后,最终能够达到一个一致的状态。因此,最终一致性的本质是需要系统保证最终数据能够达到一致,而不需要实时保证系统数据的强一致性。
\n\n\n分布式一致性的 3 种级别:
\n\n
\n- 强一致性:系统写入了什么,读出来的就是什么。
\n- 弱一致性:不一定可以读取到最新写入的值,也不保证多少时间之后读取到的数据是最新的,只是会尽量保证某个时刻达到数据一致的状态。
\n- 最终一致性:弱一致性的升级版,系统会保证在一定时间内达到数据一致的状态。
\n业界比较推崇是最终一致性级别,但是某些对数据一致要求十分严格的场景比如银行转账还是要保证强一致性。
\n
那实现最终一致性的具体方式是什么呢? 《分布式协议与算法实战》 中是这样介绍:
\n\n\n\n
\n- 读时修复 : 在读取数据时,检测数据的不一致,进行修复。比如 Cassandra 的 Read Repair 实现,具体来说,在向 Cassandra 系统查询数据的时候,如果检测到不同节点的副本数据不一致,系统就自动修复数据。
\n- 写时修复 : 在写入数据,检测数据的不一致时,进行修复。比如 Cassandra 的 Hinted Handoff 实现。具体来说,Cassandra 集群的节点之间远程写数据的时候,如果写失败 就将数据缓存下来,然后定时重传,修复数据的不一致性。
\n- 异步修复 : 这个是最常用的方式,通过定时对账检测副本数据的一致性,并修复。
\n
比较推荐 写时修复,这种方式对性能消耗比较低。
\nACID 是数据库事务完整性的理论,CAP 是分布式系统设计理论,BASE 是 CAP 理论中 AP 方案的延伸。
\n\n", "image": "https://oss.javaguide.cn/2020-11/cap.png", "date_published": "2023-04-28T09:15:47.000Z", "date_modified": "2023-10-26T22:44:02.000Z", "authors": [], "tags": [ "分布式" ] }, { "title": "Gossip 协议详解", "url": "https://javaguide.cn/distributed-system/protocol/gossip-protocl.html", "id": "https://javaguide.cn/distributed-system/protocol/gossip-protocl.html", "summary": "背景 在分布式系统中,不同的节点进行数据/信息共享是一个基本的需求。 一种比较简单粗暴的方法就是 集中式发散消息,简单来说就是一个主节点同时共享最新信息给其他所有节点,比较适合中心化系统。这种方法的缺陷也很明显,节点多的时候不光同步消息的效率低,还太依赖与中心节点,存在单点风险问题。 于是,分散式发散消息 的 Gossip 协议 就诞生了。 Gossi...", "content_html": "在分布式系统中,不同的节点进行数据/信息共享是一个基本的需求。
\n一种比较简单粗暴的方法就是 集中式发散消息,简单来说就是一个主节点同时共享最新信息给其他所有节点,比较适合中心化系统。这种方法的缺陷也很明显,节点多的时候不光同步消息的效率低,还太依赖与中心节点,存在单点风险问题。
\n于是,分散式发散消息 的 Gossip 协议 就诞生了。
\nGossip 直译过来就是闲话、流言蜚语的意思。流言蜚语有什么特点呢?容易被传播且传播速度还快,你传我我传他,然后大家都知道了。
\n\nGossip 协议 也叫 Epidemic 协议(流行病协议)或者 Epidemic propagation 算法(疫情传播算法),别名很多。不过,这些名字的特点都具有 随机传播特性 (联想一下病毒传播、癌细胞扩散等生活中常见的情景),这也正是 Gossip 协议最主要的特点。
\nGossip 协议最早是在 ACM 上的一篇 1987 年发表的论文 《Epidemic Algorithms for Replicated Database Maintenance》中被提出的。根据论文标题,我们大概就能知道 Gossip 协议当时提出的主要应用是在分布式数据库系统中各个副本节点同步数据。
\n正如 Gossip 协议其名一样,这是一种随机且带有传染性的方式将信息传播到整个网络中,并在一定时间内,使得系统内的所有节点数据一致。
\n在 Gossip 协议下,没有所谓的中心节点,每个节点周期性地随机找一个节点互相同步彼此的信息,理论上来说,各个节点的状态最终会保持一致。
\n下面我们来对 Gossip 协议的定义做一个总结:Gossip 协议是一种允许在分布式系统中共享状态的去中心化通信协议,通过这种通信协议,我们可以将信息传播给网络或集群中的所有成员。
\nNoSQL 数据库 Redis 和 Apache Cassandra、服务网格解决方案 Consul 等知名项目都用到了 Gossip 协议,学习 Gossip 协议有助于我们搞清很多技术的底层原理。
\n我们这里以 Redis Cluster 为例说明 Gossip 协议的实际应用。
\n我们经常使用的分布式缓存 Redis 的官方集群解决方案(3.0 版本引入) Redis Cluster 就是基于 Gossip 协议来实现集群中各个节点数据的最终一致性。
\n\nRedis Cluster 是一个典型的分布式系统,分布式系统中的各个节点需要互相通信。既然要相互通信就要遵循一致的通信协议,Redis Cluster 中的各个节点基于 Gossip 协议 来进行通信共享信息,每个 Redis 节点都维护了一份集群的状态信息。
\nRedis Cluster 的节点之间会相互发送多种 Gossip 消息:
\nCLUSTER MEET ip port
命令,可以向指定的 Redis 节点发送一条 MEET 信息,用于将其添加进 Redis Cluster 成为新的 Redis 节点。下图就是主从架构的 Redis Cluster 的示意图,图中的虚线代表的就是各个节点之间使用 Gossip 进行通信 ,实线表示主从复制。
\n\n有了 Redis Cluster 之后,不需要专门部署 Sentinel 集群服务了。Redis Cluster 相当于是内置了 Sentinel 机制,Redis Cluster 内部的各个 Redis 节点通过 Gossip 协议互相探测健康状态,在故障时可以自动切换。
\n关于 Redis Cluster 的详细介绍,可以查看这篇文章 Redis 集群详解(付费) 。
\nGossip 设计了两种可能的消息传播模式:反熵(Anti-Entropy) 和 传谣(Rumor-Mongering)。
\n根据维基百科:
\n\n\n熵的概念最早起源于物理学,用于度量一个热力学系统的混乱程度。熵最好理解为不确定性的量度而不是确定性的量度,因为越随机的信源的熵越大。
\n
在这里,你可以把反熵中的熵理解为节点之间数据的混乱程度/差异性,反熵就是指消除不同节点中数据的差异,提升节点间数据的相似度,从而降低熵值。
\n具体是如何反熵的呢?集群中的节点,每隔段时间就随机选择某个其他节点,然后通过互相交换自己的所有数据来消除两者之间的差异,实现数据的最终一致性。
\n在实现反熵的时候,主要有推、拉和推拉三种方式:
\n伪代码如下:
\n\n在我们实际应用场景中,一般不会采用随机的节点进行反熵,而是需要可以的设计一个闭环。这样的话,我们能够在一个确定的时间范围内实现各个节点数据的最终一致性,而不是基于随机的概率。像 InfluxDB 就是这样来实现反熵的。
\n\n虽然反熵很简单实用,但是,节点过多或者节点动态变化的话,反熵就不太适用了。这个时候,我们想要实现最终一致性就要靠 谣言传播(Rumor mongering) 。
\n谣言传播指的是分布式系统中的一个节点一旦有了新数据之后,就会变为活跃节点,活跃节点会周期性地联系其他节点向其发送新数据,直到所有的节点都存储了该新数据。
\n如下图所示(下图来自于INTRODUCTION TO GOSSIP 这篇文章):
\n![Gossip 传播示意图](./images/gossip/gossip-rumor- mongering.gif)
\n伪代码如下:
\n\n谣言传播比较适合节点数量比较多的情况,不过,这种模式下要尽量避免传播的信息包不能太大,避免网络消耗太大。
\n优势:
\n1、相比于其他分布式协议/算法来说,Gossip 协议理解起来非常简单。
\n2、能够容忍网络上节点的随意地增加或者减少,宕机或者重启,因为 Gossip 协议下这些节点都是平等的,去中心化的。新增加或者重启的节点在理想情况下最终是一定会和其他节点的状态达到一致。
\n3、速度相对较快。节点数量比较多的情况下,扩散速度比一个主节点向其他节点传播信息要更快(多播)。
\n缺陷 :
\n1、消息需要通过多个传播的轮次才能传播到整个网络中,因此,必然会出现各节点状态不一致的情况。毕竟,Gossip 协议强调的是最终一致,至于达到各个节点的状态一致需要多长时间,谁也无从得知。
\n2、由于拜占庭将军问题,不允许存在恶意节点。
\n3、可能会出现消息冗余的问题。由于消息传播的随机性,同一个节点可能会重复收到相同的消息。
\nPaxos 算法是 Leslie Lamport(莱斯利·兰伯特)在 1990 年提出了一种分布式系统 共识 算法。这也是第一个被证明完备的共识算法(前提是不存在拜占庭将军问题,也就是没有恶意节点)。
\n为了介绍 Paxos 算法,兰伯特专门写了一篇幽默风趣的论文。在这篇论文中,他虚拟了一个叫做 Paxos 的希腊城邦来更形象化地介绍 Paxos 算法。
\n不过,审稿人并不认可这篇论文的幽默。于是,他们就给兰伯特说:“如果你想要成功发表这篇论文的话,必须删除所有 Paxos 相关的故事背景”。兰伯特一听就不开心了:“我凭什么修改啊,你们这些审稿人就是缺乏幽默细胞,发不了就不发了呗!”。
\n于是乎,提出 Paxos 算法的那篇论文在当时并没有被成功发表。
\n直到 1998 年,系统研究中心 (Systems Research Center,SRC)的两个技术研究员需要找一些合适的分布式算法来服务他们正在构建的分布式系统,Paxos 算法刚好可以解决他们的部分需求。因此,兰伯特就把论文发给了他们。在看了论文之后,这俩大佬觉得论文还是挺不错的。于是,兰伯特在 1998 年重新发表论文 《The Part-Time Parliament》。
\n论文发表之后,各路学者直呼看不懂,言语中还略显调侃之意。这谁忍得了,在 2001 年的时候,兰伯特专门又写了一篇 《Paxos Made Simple》 的论文来简化对 Paxos 的介绍,主要讲述两阶段共识协议部分,顺便还不忘嘲讽一下这群学者。
\n《Paxos Made Simple》这篇论文就 14 页,相比于 《The Part-Time Parliament》的 33 页精简了不少。最关键的是这篇论文的摘要就一句话:
\n\n\n\nThe Paxos algorithm, when presented in plain English, is very simple.
\n
翻译过来的意思大概就是:当我用无修饰的英文来描述时,Paxos 算法真心简单!
\n有没有感觉到来自兰伯特大佬满满地嘲讽的味道?
\nPaxos 算法是第一个被证明完备的分布式系统共识算法。共识算法的作用是让分布式系统中的多个节点之间对某个提案(Proposal)达成一致的看法。提案的含义在分布式系统中十分宽泛,像哪一个节点是 Leader 节点、多个事件发生的顺序等等都可以是一个提案。
\n兰伯特当时提出的 Paxos 算法主要包含 2 个部分:
\n由于 Paxos 算法在国际上被公认的非常难以理解和实现,因此不断有人尝试简化这一算法。到了 2013 年才诞生了一个比 Paxos 算法更易理解和实现的共识算法—Raft 算法 。更具体点来说,Raft 是 Multi-Paxos 的一个变种,其简化了 Multi-Paxos 的思想,变得更容易被理解以及工程实现。
\n针对没有恶意节点的情况,除了 Raft 算法之外,当前最常用的一些共识算法比如 ZAB 协议、 Fast Paxos 算法都是基于 Paxos 算法改进的。
\n针对存在恶意节点的情况,一般使用的是 工作量证明(POW,Proof-of-Work)、 权益证明(PoS,Proof-of-Stake ) 等共识算法。这类共识算法最典型的应用就是区块链,就比如说前段时间以太坊官方宣布其共识机制正在从工作量证明(PoW)转变为权益证明(PoS)。
\n区块链系统使用的共识算法需要解决的核心问题是 拜占庭将军问题 ,这和我们日常接触到的 ZooKeeper、Etcd、Consul 等分布式中间件不太一样。
\n下面我们来对 Paxos 算法的定义做一个总结:
\nBasic Paxos 中存在 3 个重要的角色:
\n为了减少实现该算法所需的节点数,一个节点可以身兼多个角色。并且,一个提案被选定需要被半数以上的 Acceptor 接受。这样的话,Basic Paxos 算法还具备容错性,在少于一半的节点出现故障时,集群仍能正常工作。
\nBasic Paxos 算法的仅能就单个值达成共识,为了能够对一系列的值达成共识,我们需要用到 Basic Paxos 思想。
\n⚠️注意:Multi-Paxos 只是一种思想,这种思想的核心就是通过多个 Basic Paxos 实例就一系列值达成共识。也就是说,Basic Paxos 是 Multi-Paxos 思想的核心,Multi-Paxos 就是多执行几次 Basic Paxos。
\n由于兰伯特提到的 Multi-Paxos 思想缺少代码实现的必要细节(比如怎么选举领导者),所以在理解和实现上比较困难。
\n不过,也不需要担心,我们并不需要自己实现基于 Multi-Paxos 思想的共识算法,业界已经有了比较出名的实现。像 Raft 算法就是 Multi-Paxos 的一个变种,其简化了 Multi-Paxos 的思想,变得更容易被理解以及工程实现,实际项目中可以优先考虑 Raft 算法。
\n\n\n本文由 SnailClimb 和 Xieqijun 共同完成。
\n
当今的数据中心和应用程序在高度动态的环境中运行,为了应对高度动态的环境,它们通过额外的服务器进行横向扩展,并且根据需求进行扩展和收缩。同时,服务器和网络故障也很常见。
\n因此,系统必须在正常操作期间处理服务器的上下线。它们必须对变故做出反应并在几秒钟内自动适应;对客户来说的话,明显的中断通常是不可接受的。
\n幸运的是,分布式共识可以帮助应对这些挑战。
\n在介绍共识算法之前,先介绍一个简化版拜占庭将军的例子来帮助理解共识算法。
\n\n\n假设多位拜占庭将军中没有叛军,信使的信息可靠但有可能被暗杀的情况下,将军们如何达成是否要进攻的一致性决定?
\n
解决方案大致可以理解成:先在所有的将军中选出一个大将军,用来做出所有的决定。
\n举例如下:假如现在一共有 3 个将军 A,B 和 C,每个将军都有一个随机时间的倒计时器,倒计时一结束,这个将军就把自己当成大将军候选人,然后派信使传递选举投票的信息给将军 B 和 C,如果将军 B 和 C 还没有把自己当作候选人(自己的倒计时还没有结束),并且没有把选举票投给其他人,它们就会把票投给将军 A,信使回到将军 A 时,将军 A 知道自己收到了足够的票数,成为大将军。在有了大将军之后,是否需要进攻就由大将军 A 决定,然后再去派信使通知另外两个将军,自己已经成为了大将军。如果一段时间还没收到将军 B 和 C 的回复(信使可能会被暗杀),那就再重派一个信使,直到收到回复。
\n共识是可容错系统中的一个基本问题:即使面对故障,服务器也可以在共享状态上达成一致。
\n共识算法允许一组节点像一个整体一样一起工作,即使其中的一些节点出现故障也能够继续工作下去,其正确性主要是源于复制状态机的性质:一组Server
的状态机计算相同状态的副本,即使有一部分的Server
宕机了它们仍然能够继续运行。
图-1 复制状态机架构
一般通过使用复制日志来实现复制状态机。每个Server
存储着一份包括命令序列的日志文件,状态机会按顺序执行这些命令。因为每个日志包含相同的命令,并且顺序也相同,所以每个状态机处理相同的命令序列。由于状态机是确定性的,所以处理相同的状态,得到相同的输出。
因此共识算法的工作就是保持复制日志的一致性。服务器上的共识模块从客户端接收命令并将它们添加到日志中。它与其他服务器上的共识模块通信,以确保即使某些服务器发生故障。每个日志最终包含相同顺序的请求。一旦命令被正确地复制,它们就被称为已提交。每个服务器的状态机按照日志顺序处理已提交的命令,并将输出返回给客户端,因此,这些服务器形成了一个单一的、高度可靠的状态机。
\n适用于实际系统的共识算法通常具有以下特性:
\n安全。确保在非拜占庭条件(也就是上文中提到的简易版拜占庭)下的安全性,包括网络延迟、分区、包丢失、复制和重新排序。
\n高可用。只要大多数服务器都是可操作的,并且可以相互通信,也可以与客户端进行通信,那么这些服务器就可以看作完全功能可用的。因此,一个典型的由五台服务器组成的集群可以容忍任何两台服务器端故障。假设服务器因停止而发生故障;它们稍后可能会从稳定存储上的状态中恢复并重新加入集群。
\n一致性不依赖时序。错误的时钟和极端的消息延迟,在最坏的情况下也只会造成可用性问题,而不会产生一致性问题。
\n在集群中大多数服务器响应,命令就可以完成,不会被少数运行缓慢的服务器来影响整体系统性能。
\n一个 Raft 集群包括若干服务器,以典型的 5 服务器集群举例。在任意的时间,每个服务器一定会处于以下三个状态中的一个:
\nLeader
:负责发起心跳,响应客户端,创建日志,同步日志。Candidate
:Leader 选举过程中的临时角色,由 Follower 转化而来,发起投票参与竞选。Follower
:接受 Leader 的心跳和日志同步数据,投票给 Candidate。在正常的情况下,只有一个服务器是 Leader,剩下的服务器是 Follower。Follower 是被动的,它们不会发送任何请求,只是响应来自 Leader 和 Candidate 的请求。
\n\n图-2:服务器的状态
图-3:任期
如图 3 所示,raft 算法将时间划分为任意长度的任期(term),任期用连续的数字表示,看作当前 term 号。每一个任期的开始都是一次选举,在选举开始时,一个或多个 Candidate 会尝试成为 Leader。如果一个 Candidate 赢得了选举,它就会在该任期内担任 Leader。如果没有选出 Leader,将会开启另一个任期,并立刻开始下一次选举。raft 算法保证在给定的一个任期最少要有一个 Leader。
\n每个节点都会存储当前的 term 号,当服务器之间进行通信时会交换当前的 term 号;如果有服务器发现自己的 term 号比其他人小,那么他会更新到较大的 term 值。如果一个 Candidate 或者 Leader 发现自己的 term 过期了,他会立即退回成 Follower。如果一台服务器收到的请求的 term 号是过期的,那么它会拒绝此次请求。
\nentry
:每一个事件成为 entry,只有 Leader 可以创建 entry。entry 的内容为<term,index,cmd>
其中 cmd 是可以应用到状态机的操作。log
:由 entry 构成的数组,每一个 entry 都有一个表明自己在 log 中的 index。只有 Leader 才可以改变其他节点的 log。entry 总是先被 Leader 添加到自己的 log 数组中,然后再发起共识请求,获得同意后才会被 Leader 提交给状态机。Follower 只能从 Leader 获取新日志和当前的 commitIndex,然后把对应的 entry 应用到自己的状态机中。raft 使用心跳机制来触发 Leader 的选举。
\n如果一台服务器能够收到来自 Leader 或者 Candidate 的有效信息,那么它会一直保持为 Follower 状态,并且刷新自己的 electionElapsed,重新计时。
\nLeader 会向所有的 Follower 周期性发送心跳来保证自己的 Leader 地位。如果一个 Follower 在一个周期内没有收到心跳信息,就叫做选举超时,然后它就会认为此时没有可用的 Leader,并且开始进行一次选举以选出一个新的 Leader。
\n为了开始新的选举,Follower 会自增自己的 term 号并且转换状态为 Candidate。然后他会向所有节点发起 RequestVoteRPC 请求, Candidate 的状态会持续到以下情况发生:
\n赢得选举的条件是:一个 Candidate 在一个任期内收到了来自集群内的多数选票(N/2+1)
,就可以成为 Leader。
在 Candidate 等待选票的时候,它可能收到其他节点声明自己是 Leader 的心跳,此时有两种情况:
\n由于可能同一时刻出现多个 Candidate,导致没有 Candidate 获得大多数选票,如果没有其他手段来重新分配选票的话,那么可能会无限重复下去。
\nraft 使用了随机的选举超时时间来避免上述情况。每一个 Candidate 在发起选举后,都会随机化一个新的选举超时时间,这种机制使得各个服务器能够分散开来,在大多数情况下只有一个服务器会率先超时;它会在其他服务器超时之前赢得选举。
\n一旦选出了 Leader,它就开始接受客户端的请求。每一个客户端的请求都包含一条需要被复制状态机(Replicated State Machine
)执行的命令。
Leader 收到客户端请求后,会生成一个 entry,包含<index,term,cmd>
,再将这个 entry 添加到自己的日志末尾后,向所有的节点广播该 entry,要求其他服务器复制这条 entry。
如果 Follower 接受该 entry,则会将 entry 添加到自己的日志后面,同时返回给 Leader 同意。
\n如果 Leader 收到了多数的成功响应,Leader 会将这个 entry 应用到自己的状态机中,之后可以成为这个 entry 是 committed 的,并且向客户端返回执行结果。
\nraft 保证以下两个性质:
\n通过“仅有 Leader 可以生成 entry”来保证第一个性质,第二个性质需要一致性检查来进行保证。
\n一般情况下,Leader 和 Follower 的日志保持一致,然后,Leader 的崩溃会导致日志不一样,这样一致性检查会产生失败。Leader 通过强制 Follower 复制自己的日志来处理日志的不一致。这就意味着,在 Follower 上的冲突日志会被领导者的日志覆盖。
\n为了使得 Follower 的日志和自己的日志一致,Leader 需要找到 Follower 与它日志一致的地方,然后删除 Follower 在该位置之后的日志,接着把这之后的日志发送给 Follower。
\nLeader
给每一个Follower
维护了一个 nextIndex
,它表示 Leader
将要发送给该追随者的下一条日志条目的索引。当一个 Leader
开始掌权时,它会将 nextIndex
初始化为它的最新的日志条目索引数+1。如果一个 Follower
的日志和 Leader
的不一致,AppendEntries
一致性检查会在下一次 AppendEntries RPC
时返回失败。在失败之后,Leader
会将 nextIndex
递减然后重试 AppendEntries RPC
。最终 nextIndex
会达到一个 Leader
和 Follower
日志一致的地方。这时,AppendEntries
会返回成功,Follower
中冲突的日志条目都被移除了,并且添加所缺少的上了 Leader
的日志条目。一旦 AppendEntries
返回成功,Follower
和 Leader
的日志就一致了,这样的状态会保持到该任期结束。
Leader 需要保证自己存储全部已经提交的日志条目。这样才可以使日志条目只有一个流向:从 Leader 流向 Follower,Leader 永远不会覆盖已经存在的日志条目。
\n每个 Candidate 发送 RequestVoteRPC 时,都会带上最后一个 entry 的信息。所有节点收到投票信息时,会对该 entry 进行比较,如果发现自己的更新,则拒绝投票给该 Candidate。
\n判断日志新旧的方式:如果两个日志的 term 不同,term 大的更新;如果 term 相同,更长的 index 更新。
\n如果 Leader 崩溃,集群中的节点在 electionTimeout 时间内没有收到 Leader 的心跳信息就会触发新一轮的选主,在选主期间整个集群对外是不可用的。
\n如果 Follower 和 Candidate 崩溃,处理方式会简单很多。之后发送给它的 RequestVoteRPC 和 AppendEntriesRPC 会失败。由于 raft 的所有请求都是幂等的,所以失败的话会无限的重试。如果崩溃恢复后,就可以收到新的请求,然后选择追加或者拒绝 entry。
\nraft 的要求之一就是安全性不依赖于时间:系统不能仅仅因为一些事件发生的比预想的快一些或者慢一些就产生错误。为了保证上述要求,最好能满足以下的时间条件:
\nbroadcastTime << electionTimeout << MTBF
broadcastTime
:向其他节点并发发送消息的平均响应时间;electionTimeout
:选举超时时间;MTBF(mean time between failures)
:单台机器的平均健康时间;broadcastTime
应该比electionTimeout
小一个数量级,为的是使Leader
能够持续发送心跳信息(heartbeat)来阻止Follower
开始选举;
electionTimeout
也要比MTBF
小几个数量级,为的是使得系统稳定运行。当Leader
崩溃时,大约会在整个electionTimeout
的时间内不可用;我们希望这种情况仅占全部时间的很小一部分。
由于broadcastTime
和MTBF
是由系统决定的属性,因此需要决定electionTimeout
的时间。
一般来说,broadcastTime 一般为 0.5~20ms
,electionTimeout 可以设置为 10~500ms
,MTBF 一般为一两个月。
\n\n推荐语:很实用的工作经验分享,看完之后十分受用!
\n内容概览:
\n\n
\n\n- 要学会深入思考,总结沉淀,这是我觉得最重要也是最有意义的一件事。
\n- 积极学习,保持技术热情。如果我们积极学习,保持技术能力、知识储备与工作年限成正比,这到了 35 岁哪还有什么焦虑呢,这样的大牛我觉得应该也是各大公司抢着要吧?
\n- 在能为公司办成事,创造价值这一点上,我觉得最重要的两个字就是主动,主动承担任务,主动沟通交流,主动推动项目进展,主动协调资源,主动向上反馈,主动创造影响力等等。
\n- 脸皮要厚一点,多找人聊,快速融入,最忌讳有问题也不说,自己把自己孤立起来。
\n- 想舔就舔,不想舔也没必要酸别人,Respect Greatness。
\n- 时刻准备着,技术在手就没什么可怕的,哪天干得不爽了直接跳槽。
\n- 平时积极总结沉淀,多跟别人交流,形成方法论。
\n- ……
\n
先简单交代一下背景吧,某不知名 985 的本硕,17 年毕业加入滴滴,当时找工作时候也是在牛客这里跟大家一起奋战的。今年下半年跳槽到了头条,一直从事后端研发相关的工作。之前没有实习经历,算是两年半的工作经验吧。这两年半之间完成了一次晋升,换了一家公司,有过开心满足的时光,也有过迷茫挣扎的日子,不过还算顺利地从一只职场小菜鸟转变为了一名资深划水员。在这个过程中,总结出了一些还算实用的划水经验,有些是自己领悟到的,有些是跟别人交流学到的,在这里跟大家分享一下。
\n我想说的第一条就是要学会深入思考,总结沉淀,这是我觉得最重要也是最有意义的一件事。
\n先来说深入思考。 在程序员这个圈子里,常能听到一些言论:“我这个工作一点技术含量都没有,每天就 CRUD,再写写 if-else,这 TM 能让我学到什么东西?”
\n抛开一部分调侃和戏谑的论调不谈,这可能确实是一部分同学的真实想法,至少曾经的我,就这么认为过。后来随着工作经验的积累,加上和一些高 level 的同学交流探讨之后,我发现这个想法其实是非常错误的。之所以出现没什么可学的这样的看法,基本上是思维懒惰的结果。任何一件看起来很不起眼的小事,只要进行深入思考,稍微纵向挖深或者横向拓宽一下,都是足以让人沉溺的知识海洋。
\n举一个例子。某次有个同学跟我说,这周有个服务 OOM 了,查了一周发现有个地方 defer 写的有问题,改了几行代码上线修复了,周报都没法写。可能大家也遇到过这样的场景,还算是有一定的代表性。其实就查 bug 这件事来说,是一个发现问题,排查问题,解决问题的过程,包含了触发、定位、复现、根因、修复、复盘等诸多步骤,花了一周来做这件事,一定有不断尝试与纠错的过程,这里面其实就有很多思考的空间。比如说定位,如何缩小范围的?走了哪些弯路?用了哪些分析工具?比如说根因,可以研究的点起码有 linux 的 OOM,k8s 的 OOM,go 的内存管理,defer 机制,函数闭包的原理等等。如果这些真的都不涉及,仍然花了一周时间做这件事,那复盘应该会有很多思考,提出来几十个 WHY 没问题吧...
\n再来说下总结沉淀。 这个我觉得也是大多数程序员比较欠缺的地方,只顾埋头干活,可以把一件事做的很好。但是几乎从来不做抽象总结,以至于工作好几年了,所掌握的知识还是零星的几点,不成体系,不仅容易遗忘,而且造成自己视野比较窄,看问题比较局限。适时地做一些总结沉淀是很重要的,这是一个从术到道的过程,会让自己看问题的角度更广,层次更高。遇到同类型的问题,可以按照总结好的方法论,系统化、层次化地推进和解决。
\n还是举一个例子。做后台服务,今天优化了 1G 内存,明天优化了 50%的读写耗时,是不是可以做一下性能优化的总结?比如说在应用层,可以管理服务对接的应用方,梳理他们访问的合理性;在架构层,可以做缓存、预处理、读写分离、异步、并行等等;在代码层,可以做的事情更多了,资源池化、对象复用、无锁化设计、大 key 拆分、延迟处理、编码压缩、gc 调优还有各种语言相关的高性能实践...等下次再遇到需要性能优化的场景,一整套思路立马就能套用过来了,剩下的就是工具和实操的事儿了。
\n还有的同学说了,我就每天跟 PM 撕撕逼,做做需求,也不做性能优化啊。先不讨论是否可以搞性能优化,单就做业务需求来讲,也有可以总结的地方。比如说,如何做系统建设?系统核心能力,系统边界,系统瓶颈,服务分层拆分,服务治理这些问题有思考过吗?每天跟 PM 讨论需求,那作为技术同学该如何培养产品思维,引导产品走向,如何做到架构先行于业务,这些问题也是可以思考和总结的吧。就想一下,连接手维护别人烂代码这种蛋疼的事情,都能让 Martin Fowler 整出来一套重构理论,还显得那么高大上,我们确实也没啥必要对自己的工作妄自菲薄...
\n所以说:学习和成长是一个自驱的过程,如果觉得没什么可学的,大概率并不是真的没什么可学的,而是因为自己太懒了,不仅是行动上太懒了,思维上也太懒了。可以多写技术文章,多分享,强迫自己去思考和总结,毕竟如果文章深度不够,大家也不好意思公开分享。
\n最近两年在互联网圈里广泛传播的一种焦虑论叫做 35 岁程序员现象,大意是说程序员这个行业干到 35 岁就基本等着被裁员了。不可否认,互联网行业在这一点上确实不如公务员等体制内职业。但是,这个问题里 35 岁程序员并不是绝对生理意义上的 35 岁,应该是指那些工作十几年和工作两三年没什么太大区别的程序员。后面的工作基本是在吃老本,没有主动学习与充电,35 岁和 25 岁差不多,而且没有了 25 岁时对学习成长的渴望,反而添了家庭生活的诸多琐事,薪资要求往往也较高,在企业看来这确实是没什么竞争力。
\n如果我们积极学习,保持技术能力、知识储备与工作年限成正比,这到了 35 岁哪还有什么焦虑呢,这样的大牛我觉得应该也是各大公司抢着要吧? 但是,学习这件事,其实是一个反人类的过程,这就需要我们强迫自己跳出自己的安逸区,主动学习,保持技术热情。 在滴滴时有一句话大概是,主动跳出自己的舒适区,感到挣扎与压力的时候,往往是黎明前的黑暗,那才是成长最快的时候。相反如果感觉自己每天都过得很安逸,工作只是在混时长,那可能真的是温水煮青蛙了。
\n刚毕业的这段时间,往往空闲时间还比较多,正是努力学习技术的好时候。借助这段时间夯实基础,培养出良好的学习习惯,保持积极的学习态度,应该是受益终身的。至于如何高效率学习,网上有很多大牛写这样的帖子,到了公司后内网也能找到很多这样的分享,我就不多谈了。
\n可以加入学习小组和技术社区,公司内和公司外的都可以,关注前沿技术。
\n前两条还是从个人的角度出发来说的,希望大家可以提升个人能力,保持核心竞争力,但从公司角度来讲,公司招聘员工入职,最重要的是让员工创造出业务价值,为公司服务。虽然对于校招生一般都会有一定的培养体系,但实际上公司确实没有帮助我们成长的义务。
\n在能为公司办成事,创造价值这一点上,我觉得最重要的两个字就是主动,主动承担任务,主动沟通交流,主动推动项目进展,主动协调资源,主动向上反馈,主动创造影响力等等。
\n我当初刚入职的时候,基本就是 leader 给分配什么任务就把本职工作做好,然后就干自己的事了,几乎从来不主动去跟别人交流或者主动去思考些能帮助项目发展的点子。自以为把本职工作保质保量完成就行了,后来发现这么做其实是非常不够的,这只是最基本的要求。而有些同学的做法则是 leader 只需要同步一下最近要做什么方向,下面的一系列事情基本不需要 leader 操心了 ,这样的同学我是 leader 我也喜欢啊。入职后经常会听到的一个词叫 owner 意识,大概就是这个意思吧。
\n在这个过程中,另外很重要的一点就是及时向上沟通反馈。项目进展不顺利,遇到什么问题,及时跟 leader 同步,技术方案拿捏不准可以跟 leader 探讨,一些资源协调不了可以找 leader 帮忙,不要有太多顾忌,认为这些会太麻烦,leader 其实就是干这个事的。。如果项目进展比较顺利,确实也不需要 leader 介入,那也需要及时把项目的进度,取得的收益及时反馈,自己有什么想法也提出来探讨,问问 leader 对当前进展的建议,还有哪些地方需要改进,消除信息误差。做这些事一方面是合理利用 leader 的各种资源,另一方面也可以让 leader 了解到自己的工作量,对项目整体有所把控,毕竟 leader 也有 leader,也是要汇报的。可能算是大家比较反感的向上管理吧,有内味了,这个其实我也做得不好。但是最基本的一点,不要接了一个任务闷着头干活甚至与世隔绝了,一个月了也没跟 leader 同步过,想着憋个大招之类的,那基本凉凉。
\n一定要主动,可以先从强迫自己在各种公开场合发言开始,有问题或想法及时 one-one。
\n除了以上几点,还有一些小点我觉得也是比较重要的,列在下面:
\n无论是校招还是社招,刚入职的第一件事是非常重要的,直接决定了 leader 和同事对自己的第一印象。入职后要做的第一件事一定要做好,最起码的要顺利完成而且不能出线上事故。这件事的目的就是为了建立信任,让团队觉得自己起码是靠谱的。如果这件事做得比较好,后面一路都会比较顺利。如果这件事就搞杂了,可能有的 leader 还会给第二次机会,再搞不好,后面就很难了,这一条对于社招来说更为重要。
\n而刚入职,公司技术栈不熟练,业务繁杂很难理清什么头绪,压力确实比较大。这时候一方面需要自己投入更多的精力,另一方面要多跟组内的同学交流,不懂就问。最有效率的学习方式,我觉得不是什么看书啊学习视频啊,而是直接去找对应的人聊,让别人讲一遍自己基本就全懂了,这效率比看文档看代码快多了,不仅省去了过滤无用信息的过程,还了解到了业务的演变历史。当然,这需要一定的沟通技巧,毕竟同事们也都很忙。
\n脸皮要厚一点,多找人聊,快速融入,最忌讳有问题也不说,自己把自己孤立起来。
\n超出预期这个词的外延范围很广,比如 leader 让去做个值周,解答用户群里大家的问题,结果不仅解答了大家的问题,还收集了这些问题进行分类,进而做了一个智能问答机器人解放了值周的人力,这可以算超出预期。比如 leader 让给运营做一个小工具,结果建设了一系列的工具甚至发展成了一个平台,成为了一个完整的项目,这也算超出预期。超出预期要求我们有把事情做大的能力,也就是想到了 leader 没想到的地方,并且创造了实际价值,拿到了业务收益。这个能力其实也比较重要,在工作中发现,有的人能把一个小盘子越做越大,而有的人恰好反之,那么那些有创新能力,经常超出预期的同学发展空间显然就更大一点。
\n这块其实比较看个人能力,暂时没想到什么太好的捷径,多想一步吧。
\n这句话是晋升时候总结出来的,大意就是做系统建设要有全局视野,不要局限于某一个小点,应该有良好的规划能力和清晰的演进蓝图。比如,今天加了一个监控,明天加一个报警,这些事不应该成为一个个孤岛,而是属于稳定性建设一期其中的一小步。这一期稳定性建设要做的工作是报警配置和监控梳理,包括机器监控、系统监控、业务监控、数据监控等,预期能拿到 XXX 的收益。这个工作还有后续的 roadmap,稳定性建设二期要做容量规划,接入压测,三期要做降级演练,多活容灾,四期要做...给人的感觉就是这个人思考非常全面,办事有体系有规划。
\n平时积极总结沉淀,多跟别人交流,形成方法论。
\n这里的软素质能力其实想说的就是 PPT、沟通、表达、时间管理、设计、文档等方面的能力。说实话,我觉得我当时能晋升就是因为 PPT 做的好了一点...可能大家平时对这些能力都不怎么关注,以前我也不重视,觉得比较简单,用时候直接上就行了,但事实可能并不像想象得那样简单。比如晋升时候 PPT+演讲+答辩这个工作,其实有很多细节的思考在里面,内容如何选取,排版怎么设计,怎样引导听众的情绪,如何回答评委的问题等等。晋升时候我见过很多同学 PPT 内容编排杂乱无章,演讲过程也不流畅自然,虽然确实做了很多实际工作,但在表达上欠缺了很多,属于会做不会说,如果再遇到不了解实际情况的外部门评委,吃亏是可以预见的。
\n公司内网一般都会有一些软素质培训课程,可以找一些场合刻意训练。
\n以上都是这些分享还都算比较伟光正,但是社会吧也不全是那么美好的。。下面这些内容有负能量倾向,三观特别正的同学以及观感不适者建议跳过。
\n拍马屁这东西入职前我是很反感的,我最初想加入互联网公司的原因就是觉得互联网公司的人情世故没那么多,事实证明,我错了...入职前几天,部门群里大 leader 发了一条消息,后面几十条带着大拇指的消息立马跟上,学习了,点赞,真不错,优秀,那场面,说是红旗招展锣鼓喧天鞭炮齐鸣一点也不过分。除了惊叹大家超强的信息接收能力和处理速度外,更进一步我还发现,连拍马屁都是有队形的,一级部门 leader 发消息,几个二级部门 leader 跟上,后面各组长跟上,最后是大家的狂欢,让我一度怀疑拍马屁的速度就决定了职业生涯的发展前景(没错,现在我已经不怀疑了)。
\n坦诚地说,我到现在也没习惯在群里拍马屁,但也不反感了,可以说把这个事当成一乐了。倒不是说我没有那个口才和能力(事实上也不需要什么口才,大家都简单直接),在某些场合,为活跃气氛的需要,我也能小嘴儿抹了蜜,甚至能把古诗文彩虹屁给 leader 安排上。而是我发现我的直属 leader 也不怎么在群里拍马屁,所以我表面上不公开拍马屁其实属于暗地里事实上迎合了 leader 的喜好...
\n但是拍马屁这个事只要掌握好度,整体来说还是香的,最多是没用,至少不会有什么坏处嘛。大家能力都差不多,每一次在群里拍马屁的机会就是一次露脸的机会,按某个同事的说法,这就叫打造个人技术影响力...
\n想舔就舔,不想舔也没必要酸别人,Respect Greatness。
\n有人的地方,就有江湖。虽然搞技术的大多城府也不深,但撕逼甩锅邀功抢活这些闹心的事儿基本也不会缺席,甚至我还见到过公开群发邮件撕逼的...这部分话题涉及到一些敏感信息就不多说了,而且我们低职级的遇到这些事儿的机会也不会太多。只是给大家提个醒,在工作的时候迟早都会吃到这方面的瓜,到时候留个心眼。
\n稍微注意一下,咱不会去欺负别人,但也不能轻易让别人给欺负了。
\n说实话,我个人是比较反感灌鸡汤、打鸡血、谈梦想、讲奋斗这一类行为的,9102 年都快过完了,这一套***治还在大行其道,真不知道是该可笑还是可悲。当然,这些词本身并没有什么问题,但是这些东西应该是自驱的,而不应该成为外界的一种强 push。『我必须努力奋斗』这个句式我觉得是正常的,但是『你必须努力奋斗』这种话多少感觉有点诡异,努力奋斗所以让公司的股东们发家致富?尤其在钱没给够的情况下,这些行为无异于耍流氓。我们需要对 leader 的这些画饼操作保持清醒的认知,理性分析,作出决策。比如感觉钱没给够(或者职级太低,同理)的时候,可能有以下几种情况:
\n这时候我们需要做的是向上反馈,跟 leader 沟通确认。如果是 1 和 2,那么通过沟通可以消除信息误差。如果是 3,需要分情况讨论。如果是 4 和 5,已经可以考虑撤退了。对于这些事儿,也没必要抱怨,抱怨解决不了任何问题。我们要做的就是努力提升好个人能力,保持个人竞争力,等一个合适的时机,跳槽就完事了。
\n时刻准备着,技术在手就没什么可怕的,哪天干得不爽了直接跳槽。
\n这一条说白了就是,要会吹。忘了从哪儿看到的了,能说、会写、善做是对职场人的三大要求。能说是很重要的,能说才能要来项目,拉来资源,招来人。同样一件事,不同的人能说出来完全不一样的效果。比如我做了个小工具上线了,我就只能说出来基本事实,而让 leader 描述一下,这就成了,打造了 XXX 的工具抓手,改进了 XXX 的完整生态,形成了 XXX 的业务闭环。老哥,我服了,硬币全给你还不行嘛。据我的观察,每个互联网公司都有这么几个词,抓手、生态、闭环、拉齐、梳理、迭代、owner 意识等等等等,我们需要做的就是熟读并背诵全文,啊不,是牢记并熟练使用。
\n这是对事情的包装,对人的包装也是一样的,尤其是在晋升和面试这样的应试型场合,特点是流程短一锤子买卖,包装显得尤为重要。晋升和面试这里就不展开说了,这里面的道和术太多了。。下面的场景提炼自面试过程中和某公司面试官的谈话,大家可以感受一下:
\n人生如戏,全靠演技
\n可以多看 leader 的 PPT,多听老板的向上汇报和宣讲会。
\n这还用问么,当然是选择。在完美的选择面前,努力显得一文不值,我有个多年没联系的高中同学今年已经在时代广场敲钟了...但是这样的案例太少了,做出完美选择的随机成本太高,不确定性太大。对于大多数刚毕业的同学,对行业的判断力还不够成熟,对自身能力和创业难度把握得也不够精准,此时拉几个人去创业,显得风险太高。我觉得更为稳妥的一条路是,先加入规模稍大一点的公司,找一个好 leader,抱好大腿,提升自己的个人能力。好平台加上大腿,再加上个人努力,这个起飞速度已经可以了。等后面积累了一定人脉和资金,深刻理解了市场和需求,对自己有信心了,可以再去考虑创业的事。
\n本来还想分享一些生活方面的故事,发现已经这么长了,那就先这样叭。上面写的一些总结和建议我自己做的也不是很好,还需要继续加油,和大家共勉。另外,其中某些观点,由于个人视角的局限性也不保证是普适和正确的,可能再工作几年这些观点也会发生改变,欢迎大家跟我交流~(甩锅成功)
\n最后祝大家都能找到心仪的工作,快乐工作,幸福生活,广阔天地,大有作为。
\n\n", "date_published": "2023-04-28T09:15:47.000Z", "date_modified": "2023-12-30T09:14:13.000Z", "authors": [], "tags": [ "技术文章精选集" ] }, { "title": "JWT 身份认证优缺点分析", "url": "https://javaguide.cn/system-design/security/advantages-and-disadvantages-of-jwt.html", "id": "https://javaguide.cn/system-design/security/advantages-and-disadvantages-of-jwt.html", "summary": "在 JWT 基本概念详解这篇文章中,我介绍了: 什么是 JWT? JWT 由哪些部分组成? 如何基于 JWT 进行身份验证? JWT 如何防止 Token 被篡改? 如何加强 JWT 的安全性? 这篇文章,我们一起探讨一下 JWT 身份认证的优缺点以及常见问题的解决办法。 JWT 的优势 相比于 Session 认证的方式来说,使用 JWT 进行身份认...", "content_html": "在 JWT 基本概念详解这篇文章中,我介绍了:
\n这篇文章,我们一起探讨一下 JWT 身份认证的优缺点以及常见问题的解决办法。
\n相比于 Session 认证的方式来说,使用 JWT 进行身份认证主要有下面 4 个优势。
\nJWT 自身包含了身份验证所需要的所有信息,因此,我们的服务器不需要存储 Session 信息。这显然增加了系统的可用性和伸缩性,大大减轻了服务端的压力。
\n不过,也正是由于 JWT 的无状态,也导致了它最大的缺点:不可控!
\n就比如说,我们想要在 JWT 有效期内废弃一个 JWT 或者更改它的权限的话,并不会立即生效,通常需要等到有效期过后才可以。再比如说,当用户 Logout 的话,JWT 也还有效。除非,我们在后端增加额外的处理逻辑比如将失效的 JWT 存储起来,后端先验证 JWT 是否有效再进行处理。具体的解决办法,我们会在后面的内容中详细介绍到,这里只是简单提一下。
\nCSRF(Cross Site Request Forgery) 一般被翻译为 跨站请求伪造,属于网络攻击领域范围。相比于 SQL 脚本注入、XSS 等安全攻击方式,CSRF 的知名度并没有它们高。但是,它的确是我们开发系统时必须要考虑的安全隐患。就连业内技术标杆 Google 的产品 Gmail 也曾在 2007 年的时候爆出过 CSRF 漏洞,这给 Gmail 的用户造成了很大的损失。
\n那么究竟什么是跨站请求伪造呢? 简单来说就是用你的身份去做一些不好的事情(发送一些对你不友好的请求比如恶意转账)。
\n举个简单的例子:小壮登录了某网上银行,他来到了网上银行的帖子区,看到一个帖子下面有一个链接写着“科学理财,年盈利率过万”,小壮好奇的点开了这个链接,结果发现自己的账户少了 10000 元。这是这么回事呢?原来黑客在链接中藏了一个请求,这个请求直接利用小壮的身份给银行发送了一个转账请求,也就是通过你的 Cookie 向银行发出请求。
\n<a src=\"http://www.mybank.com/Transfer?bankId=11&money=10000\"\n >科学理财,年盈利率过万</a\n>\n
CSRF 攻击需要依赖 Cookie ,Session 认证中 Cookie 中的 SessionID
是由浏览器发送到服务端的,只要发出请求,Cookie 就会被携带。借助这个特性,即使黑客无法获取你的 SessionID
,只要让你误点攻击链接,就可以达到攻击效果。
另外,并不是必须点击链接才可以达到攻击效果,很多时候,只要你打开了某个页面,CSRF 攻击就会发生。
\n<img src=\"http://www.mybank.com/Transfer?bankId=11&money=10000\" />\n
那为什么 JWT 不会存在这种问题呢?
\n一般情况下我们使用 JWT 的话,在我们登录成功获得 JWT 之后,一般会选择存放在 localStorage 中。前端的每一个请求后续都会附带上这个 JWT,整个过程压根不会涉及到 Cookie。因此,即使你点击了非法链接发送了请求到服务端,这个非法请求也是不会携带 JWT 的,所以这个请求将是非法的。
\n总结来说就一句话:使用 JWT 进行身份验证不需要依赖 Cookie ,因此可以避免 CSRF 攻击。
\n不过,这样也会存在 XSS 攻击的风险。为了避免 XSS 攻击,你可以选择将 JWT 存储在标记为httpOnly
的 Cookie 中。但是,这样又导致了你必须自己提供 CSRF 保护,因此,实际项目中我们通常也不会这么做。
常见的避免 XSS 攻击的方式是过滤掉请求中存在 XSS 攻击风险的可疑字符串。
\n在 Spring 项目中,我们一般是通过创建 XSS 过滤器来实现的。
\n@Component\n@Order(Ordered.HIGHEST_PRECEDENCE)\npublic class XSSFilter implements Filter {\n\n @Override\n public void doFilter(ServletRequest request, ServletResponse response,\n FilterChain chain) throws IOException, ServletException {\n XSSRequestWrapper wrappedRequest =\n new XSSRequestWrapper((HttpServletRequest) request);\n chain.doFilter(wrappedRequest, response);\n }\n\n // other methods\n}\n
使用 Session 进行身份认证的话,需要保存一份信息在服务器端,而且这种方式会依赖到 Cookie(需要 Cookie 保存 SessionId
),所以不适合移动端。
但是,使用 JWT 进行身份认证就不会存在这种问题,因为只要 JWT 可以被客户端存储就能够使用,而且 JWT 还可以跨语言使用。
\n使用 Session 进行身份认证的话,实现单点登录,需要我们把用户的 Session 信息保存在一台电脑上,并且还会遇到常见的 Cookie 跨域的问题。但是,使用 JWT 进行认证的话, JWT 被保存在客户端,不会存在这些问题。
\n与之类似的具体相关场景有:
\n这个问题不存在于 Session 认证方式中,因为在 Session 认证方式中,遇到这种情况的话服务端删除对应的 Session 记录即可。但是,使用 JWT 认证的方式就不好解决了。我们也说过了,JWT 一旦派发出去,如果后端不增加其他逻辑的话,它在失效之前都是有效的。
\n那我们如何解决这个问题呢?查阅了很多资料,我简单总结了下面 4 种方案:
\n1、将 JWT 存入内存数据库
\n将 JWT 存入 DB 中,Redis 内存数据库在这里是不错的选择。如果需要让某个 JWT 失效就直接从 Redis 中删除这个 JWT 即可。但是,这样会导致每次使用 JWT 发送请求都要先从 DB 中查询 JWT 是否存在的步骤,而且违背了 JWT 的无状态原则。
\n2、黑名单机制
\n和上面的方式类似,使用内存数据库比如 Redis 维护一个黑名单,如果想让某个 JWT 失效的话就直接将这个 JWT 加入到 黑名单 即可。然后,每次使用 JWT 进行请求的话都会先判断这个 JWT 是否存在于黑名单中。
\n前两种方案的核心在于将有效的 JWT 存储起来或者将指定的 JWT 拉入黑名单。
\n虽然这两种方案都违背了 JWT 的无状态原则,但是一般实际项目中我们通常还是会使用这两种方案。
\n3、修改密钥 (Secret) :
\n我们为每个用户都创建一个专属密钥,如果我们想让某个 JWT 失效,我们直接修改对应用户的密钥即可。但是,这样相比于前两种引入内存数据库带来了危害更大:
\n4、保持令牌的有效期限短并经常轮换
\n很简单的一种方式。但是,会导致用户登录状态不会被持久记录,而且需要用户经常登录。
\n另外,对于修改密码后 JWT 还有效问题的解决还是比较容易的。说一种我觉得比较好的方式:使用用户的密码的哈希值对 JWT 进行签名。因此,如果密码更改,则任何先前的令牌将自动无法验证。
\nJWT 有效期一般都建议设置的不太长,那么 JWT 过期后如何认证,如何实现动态刷新 JWT,避免用户经常需要重新登录?
\n我们先来看看在 Session 认证中一般的做法:假如 Session 的有效期 30 分钟,如果 30 分钟内用户有访问,就把 Session 有效期延长 30 分钟。
\nJWT 认证的话,我们应该如何解决续签问题呢?查阅了很多资料,我简单总结了下面 4 种方案:
\n1、类似于 Session 认证中的做法
\n这种方案满足于大部分场景。假设服务端给的 JWT 有效期设置为 30 分钟,服务端每次进行校验时,如果发现 JWT 的有效期马上快过期了,服务端就重新生成 JWT 给客户端。客户端每次请求都检查新旧 JWT,如果不一致,则更新本地的 JWT。这种做法的问题是仅仅在快过期的时候请求才会更新 JWT ,对客户端不是很友好。
\n2、每次请求都返回新 JWT
\n这种方案的的思路很简单,但是,开销会比较大,尤其是在服务端要存储维护 JWT 的情况下。
\n3、JWT 有效期设置到半夜
\n这种方案是一种折衷的方案,保证了大部分用户白天可以正常登录,适用于对安全性要求不高的系统。
\n4、用户登录返回两个 JWT
\n第一个是 accessJWT ,它的过期时间 JWT 本身的过期时间比如半个小时,另外一个是 refreshJWT 它的过期时间更长一点比如为 1 天。客户端登录后,将 accessJWT 和 refreshJWT 保存在本地,每次访问将 accessJWT 传给服务端。服务端校验 accessJWT 的有效性,如果过期的话,就将 refreshJWT 传给服务端。如果有效,服务端就生成新的 accessJWT 给客户端。否则,客户端就重新登录即可。
\n这种方案的不足是:
\nJWT 其中一个很重要的优势是无状态,但实际上,我们想要在实际项目中合理使用 JWT 的话,也还是需要保存 JWT 信息。
\nJWT 也不是银弹,也有很多缺陷,具体是选择 JWT 还是 Session 方案还是要看项目的具体需求。万万不可尬吹 JWT,而看不起其他身份认证方案。
\n另外,不用 JWT 直接使用普通的 Token(随机生成,不包含具体的信息) 结合 Redis 来做身份认证也是可以的。我在 「优质开源项目推荐」的第 8 期推荐过的 Sa-Token 这个项目是一个比较完善的 基于 JWT 的身份认证解决方案,支持自动续签、踢人下线、账号封禁、同端互斥登录等功能,感兴趣的朋友可以看看。
\n\n友情提示
\n本文节选自 《Java 面试指北》。这是一份教你如何更高效地准备面试的小册,涵盖常见八股文(系统设计、常见框架、分布式、高并发 ……)、优质面经等内容。
\n准备面试的时候,具体哪些知识点是重点呢?
\n给你几点靠谱的建议:
\n看似 Java 后端八股文很多,实际把复习范围一缩小,重要的东西就是那些。考虑到时间问题,你不可能连一些比较冷门的知识点也给准备了。这没必要,主要精力先放在那些重要的知识点即可。
\n对于技术八股文来说,尽量不要死记硬背,这种方式非常枯燥且对自身能力提升有限!但是!想要一点不背是不太现实的,只是说要结合实际应用场景和实战来理解记忆。
\n我一直觉得面试八股文最好是和实际应用场景和实战相结合。很多同学现在的方向都错了,上来就是直接背八股文,硬生生学成了文科,那当然无趣了。
\n举个例子:你的项目中需要用到 Redis 来做缓存,你对照着官网简单了解并实践了简单使用 Redis 之后,你去看了 Redis 对应的八股文。你发现 Redis 可以用来做限流、分布式锁,于是你去在项目中实践了一下并掌握了对应的八股文。紧接着,你又发现 Redis 内存不够用的情况下,还能使用 Redis Cluster 来解决,于是你就又去实践了一下并掌握了对应的八股文。
\n一定要记住你的主要目标是理解和记关键词,而不是像背课文一样一字一句地记下来!
\n另外,记录博客或者用自己的理解把对应的知识点讲给别人听也是一个不错的选择。
\n最后,准备技术面试的同学一定要定期复习(自测的方式非常好),不然确实会遗忘的。
\n", "date_published": "2023-04-22T02:34:42.000Z", "date_modified": "2023-10-10T03:03:34.000Z", "authors": [], "tags": [ "面试准备" ] }, { "title": "计算机网络常见面试题总结(下)", "url": "https://javaguide.cn/cs-basics/network/other-network-questions2.html", "id": "https://javaguide.cn/cs-basics/network/other-network-questions2.html", "summary": "下篇主要是传输层和网络层相关的内容。 TCP 与 UDP TCP 与 UDP 的区别(重要) 是否面向连接:UDP 在传送数据之前不需要先建立连接。而 TCP 提供面向连接的服务,在传送数据之前必须先建立连接,数据传送结束后要释放连接。 是否是可靠传输:远地主机在收到 UDP 报文后,不需要给出任何确认,并且不保证数据不丢失,不保证是否顺序到达。TCP...", "content_html": "下篇主要是传输层和网络层相关的内容。
\n我把上面总结的内容通过表格形式展示出来了!确定不点个赞嘛?
\n| | TCP | UDP |
\n|
DNS(Domain Name System)域名管理系统,是当用户使用浏览器访问网址之后,使用的第一个重要协议。DNS 要解决的是域名和 IP 地址的映射问题。
\n\n在实际使用中,有一种情况下,浏览器是可以不必动用 DNS 就可以获知域名和 IP 地址的映射的。浏览器在本地会维护一个hosts
列表,一般来说浏览器要先查看要访问的域名是否在hosts
列表中,如果有的话,直接提取对应的 IP 地址记录,就好了。如果本地hosts
列表内没有域名-IP 对应记录的话,那么 DNS 就闪亮登场了。
目前 DNS 的设计采用的是分布式、层次数据库结构,DNS 是应用层协议,基于 UDP 协议之上,端口为 53 。
\n\nDNS 服务器自底向上可以依次分为以下几个层级(所有 DNS 服务器都属于以下四个类别之一):
\ncom
、org
、net
和edu
等。国家也有自己的顶级域,如uk
、fr
和ca
。TLD 服务器提供了权威 DNS 服务器的 IP 地址。世界上并不是只有 13 台根服务器,这是很多人普遍的误解,网上很多文章也是这么写的。实际上,现在根服务器数量远远超过这个数量。最初确实是为 DNS 根服务器分配了 13 个 IP 地址,每个 IP 地址对应一个不同的根 DNS 服务器。然而,由于互联网的快速发展和增长,这个原始的架构变得不太适应当前的需求。为了提高 DNS 的可靠性、安全性和性能,目前这 13 个 IP 地址中的每一个都有多个服务器,截止到 2023 年底,所有根服务器之和达到了 600 多台,未来还会继续增加。
\n以下图为例,介绍 DNS 的查询解析过程。DNS 的查询解析过程分为两种模式:
\n下图是实践中常采用的方式,从请求主机到本地 DNS 服务器的查询是递归的,其余的查询时迭代的。
\n\n现在,主机cis.poly.edu
想知道gaia.cs.umass.edu
的 IP 地址。假设主机cis.poly.edu
的本地 DNS 服务器为dns.poly.edu
,并且gaia.cs.umass.edu
的权威 DNS 服务器为dns.cs.umass.edu
。
cis.poly.edu
向本地 DNS 服务器dns.poly.edu
发送一个 DNS 请求,该查询报文包含被转换的域名gaia.cs.umass.edu
。dns.poly.edu
检查本机缓存,发现并无记录,也不知道gaia.cs.umass.edu
的 IP 地址该在何处,不得不向根服务器发送请求。edu
顶级域,因此告诉本地 DNS,你可以向edu
的 TLD DNS 发送请求,因为目标域名的 IP 地址很可能在那里。edu
的 TLD DNS 服务器地址,向其发送请求,询问gaia.cs.umass.edu
的 IP 地址。edu
的 TLD DNS 服务器仍不清楚请求域名的 IP 地址,但是它注意到该域名有umass.edu
前缀,因此返回告知本地 DNS,umass.edu
的权威服务器可能记录了目标域名的 IP 地址。dns.cs.umass.edu
。gaia.cs.umass.edu
向权威 DNS 服务器备案过,在这里有它的 IP 地址记录,权威 DNS 成功地将 IP 地址返回给本地 DNS。除了迭代式查询,还有一种递归式查询如下图,具体过程和上述类似,只是顺序有所不同。
\n\n另外,DNS 的缓存位于本地 DNS 服务器。由于全世界的根服务器甚少,只有 600 多台,分为 13 组,且顶级域的数量也在一个可数的范围内,因此本地 DNS 通常已经缓存了很多 TLD DNS 服务器,所以在实际查找过程中,无需访问根服务器。根服务器通常是被跳过的,不请求的。这样可以提高 DNS 查询的效率和速度,减少对根服务器和 TLD 服务器的负担。
\nDNS 的报文格式如下图所示:
\n\nDNS 报文分为查询和回答报文,两种形式的报文结构相同。
\n0
表示查询报文,1
表示回答报文;1 比特的”权威的“标志位(当某 DNS 服务器是所请求名字的权威 DNS 服务器时,且是回答报文,使用”权威的“标志);1 比特的”希望递归“标志位,显式地要求执行递归查询;1 比特的”递归可用“标志位,用于回答报文中,表示 DNS 服务器支持递归查询。DNS 服务器在响应查询时,需要查询自己的数据库,数据库中的条目被称为资源记录(Resource Record,RR)。RR 提供了主机名到 IP 地址的映射。RR 是一个包含了Name
, Value
, Type
, TTL
四个字段的四元组。
TTL
是该记录的生存时间,它决定了资源记录应当从缓存中删除的时间。
Name
和Value
字段的取值取决于Type
:
Type=A
,则Name
是主机名信息,Value
是该主机名对应的 IP 地址。这样的 RR 记录了一条主机名到 IP 地址的映射。Type=AAAA
(与 A
记录非常相似),唯一的区别是 A 记录使用的是 IPv4,而 AAAA
记录使用的是 IPv6。Type=CNAME
(Canonical Name Record,真实名称记录) ,则Value
是别名为Name
的主机对应的规范主机名。Value
值才是规范主机名。CNAME
记录将一个主机名映射到另一个主机名。CNAME
记录用于为现有的 A
记录创建别名。下文有示例。Type=NS
,则Name
是个域,而Value
是个知道如何获得该域中主机 IP 地址的权威 DNS 服务器的主机名。通常这样的 RR 是由 TLD 服务器发布的。Type=MX
,则Value
是个别名为Name
的邮件服务器的规范主机名。既然有了 MX
记录,那么邮件服务器可以和其他服务器使用相同的别名。为了获得邮件服务器的规范主机名,需要请求 MX
记录;为了获得其他服务器的规范主机名,需要请求 CNAME
记录。CNAME
记录总是指向另一则域名,而非 IP 地址。假设有下述 DNS zone:
NAME TYPE VALUE\n
坚持写技术博客已经有六年了,也算是一个小小的里程碑了。
\n一开始,我写技术博客就是简单地总结自己课堂上学习的课程比如网络、操作系统。渐渐地,我开始撰写一些更为系统化的知识点详解和面试常见问题总结。
\n\n许多人都想写技术博客,但却不清楚这对他们有何好处。有些人开始写技术博客,却不知道如何坚持下去,也不知道该写些什么。这篇文章我会认真聊聊我对记录技术博客的一些看法和心得,或许可以帮助你解决这些问题。
\n费曼学习法 大家应该已经比较清楚了,这是一个经过实践证明非常有效的学习方式。费曼学习法的命名源自 Richard Feynman,这位物理学家曾获得过诺贝尔物理学奖,也曾参与过曼哈顿计划。
\n所谓费曼学习法,就是当你学习了一个新知识之后,想象自己是一个老师:用最简单、最浅显直白的话复述、表达复杂深奥的知识,最好不要使用行业术语,让非行业内的人也能听懂。为了达到这种效果,最好想象你是在给一个 80 多岁或 8 岁的小孩子上课,甚至他们都能听懂。
\n\n看书、看视频这类都属于是被动学习,学习效果比较差。费曼学习方法属于主动学习,学习效果非常好。
\n写技术博客实际就是教别人的一种方式。 不过,记录技术博客的时候是可以有专业术语(除非你的文章群体是非技术人员),只是你需要用自己的话表述出来,尽量让别人一看就懂。切忌照搬书籍或者直接复制粘贴其他人的总结!
\n如果我们被动的学习某个知识点,可能大部分时候都是仅仅满足自己能够会用的层面,你并不会深究其原理,甚至很多关键概念都没搞懂。
\n如果你是要将你所学到的知识总结成一篇博客的话,一定会加深你对这个知识点的思考。很多时候,你为了将一个知识点讲清楚,你回去查阅很多资料,甚至需要查看很多源码,这些细小的积累在潜移默化中加深了你对这个知识点的认识。
\n甚至,我还经常会遇到这种情况:写博客的过程中,自己突然意识到自己对于某个知识点的理解存在错误。
\n写博客本身就是一个对自己学习到的知识进行总结、回顾、思考的过程。记录博客也是对于自己学习历程的一种记录。随着时间的流逝、年龄的增长,这又何尝不是一笔宝贵的精神财富呢?
\n知识星球的一位球友还提到写技术博客有助于完善自己的知识体系:
\n\n就像我们程序员希望自己的产品能够得到大家的认可和喜欢一样。我们写技术博客在某一方面当然也是为了能够得到别人的认可。
\n当你写的东西对别人产生帮助的时候,你会产生成就感和幸福感。
\n\n这种成就感和幸福感会作为 正向反馈 ,继续激励你写博客。
\n但是,即使受到很多读者的赞赏,也要保持谦虚学习的太多。人外有人,比你技术更厉害的读者多了去,一定要虚心学习!
\n当然,你可以可能会受到很多非议。可能会有很多人说你写的文章没有深度,还可能会有很多人说你闲的蛋疼,你写的东西网上/书上都有。
\n坦然对待这些非议,做好自己,走好自己的路就好!用行动自证!
\n写博客可能还会为你带来经济收入。输出价值的同时,还能够有合理的经济收入,这是最好的状态!
\n为什么说是可能呢? 因为就目前来看,大部分人还是很难短期通过写博客有收入。我也不建议大家一开始写博客就奔着赚钱的目的,这样功利性太强了,效果可能反而不好。就比如说你坚持了写了半年发现赚不到钱,那你可能就会坚持不下去了。
\n我自己从大二开始写博客,大三下学期开始将自己的文章发布到公众号上,一直到大四下学期,才通过写博客赚到属于自己的第一笔钱。
\n第一笔钱是通过微信公众号接某培训机构的推广获得的。没记错的话,当时通过这个推广为自己带来了大约 500 元的收入。虽然这不是很多,但对于还在上大学的我来说,这笔钱非常宝贵。那时我才知道,原来写作真的可以赚钱,这也让我更有动力去分享自己的写作。可惜的是,在接了两次这家培训机构的广告之后,它就倒闭了。
\n之后,很长一段时间我都没有接到过广告。直到网易的课程合作找上门,一篇文章 1000 元,每个月接近一篇,发了接近两年,这也算是我在大学期间比较稳定的一份收入来源了。
\n\n老粉应该大部分都是通过 JavaGuide 这个项目认识我的,这是我在大三开始准备秋招面试时创建的一个项目。没想到这个项目竟然火了一把,一度霸占了 GitHub 榜单。可能当时国内这类开源文档教程类项目太少了,所以这个项目受欢迎程度非常高。
\n\n项目火了之后,有一个国内比较大的云服务公司找到我,说是要赞助 JavaGuide 这个项目。我既惊又喜,担心别人是骗子,反复确认合同之后,最终确定以每月 1000 元的费用在我的项目首页加上对方公司的 banner。
\n随着时间的推移,以及自己后来写了一些比较受欢迎、比较受众的文章,我的博客知名度也有所提升,通过写博客的收入也增加了不少。
\n写技术博客是一种展示自己技术水平和经验的方式,能够让更多的人了解你的专业领域知识和技能。持续分享优质的技术文章,一定能够在技术领域增加个人影响力,这一点是毋庸置疑的。
\n有了个人影响力之后,不论是对你后面找工作,还是搞付费知识分享或者出书,都非常有帮助。
\n拿我自己来说,已经很多知名出版社的编辑找过我,协商出一本的书的事情。这种机会应该也是很多人梦寐以求的。不过,我都一一拒绝了,因为觉得自己远远没有达到能够写书的水平。
\n\n其实不出书最主要的原因还是自己嫌麻烦,整个流程的事情太多了。我自己又是比较佛系随性的人,平时也不想把时间都留给工作。
\n不可否认,人都是有懒性的,这是人的本性。我们需要一个目标/动力来 Push 一下自己。
\n就技术写作而言,你的目标可以以技术文章的数量为标准,比如:
\n不过,以技术文章的数量为目标有点功利化,文章的质量同样很重要。一篇高质量的技术文可能需要花费一周甚至半个月的业余时间才能写完。一定要避免自己刻意追求数量,而忽略质量,迷失技术写作的本心。
\n我个人给自己定的目标是:每个月至少写一篇原创技术文章或者认真修改完善过去写的三篇技术文章 (像开源项目推荐、开源项目学习、个人经验分享、面经分享等等类型的文章不会被记入)。
\n我的目标对我来说比较容易完成,因此不会出现为了完成目标而应付任务的情况。在我状态比较好,工作也不是很忙的时候,还会经常超额完成任务。下图是我今年 3 月份完成的任务(任务管理工具:Microsoft To-Do)。除了 gossip 协议是去年写的之外,其他都是 3 月份完成的。
\n\n如果觉得以文章数量为标准过于功利的话,也可以比较随性地按照自己的节奏来写作。不过,一般这种情况下,你很可能过段时间就忘了还有这件事,开始慢慢抵触写博客。
\n写完一篇技术文章之后,我们不光要同步到自己的博客,还要分发到国内一些常见的技术社区比如博客园、掘金。分发到其他平台的原因是获得关注进而收获正向反馈(动力来源之一)与建议,这是技术写作能坚持下去的非常重要的一步,一定要重视!!!
\n说实话,当你写完一篇自认为还不错的文章的幸福感和成就感还是有的。但是,让自己去做这件事情还是比较痛苦的。 就好比你让自己出去玩很简单,为了达到这个目的,你可以有各种借口。但是,想要自己老老实实学习,还是需要某个外力来督促自己的。
\n通常来说,写下面这些方向的博客会比较好:
\n最重要的是一定要重视 Markdown 规范,不然内容再好也会显得不专业。
\n详见 Markdown 规范 (很重要,尽量按照规范来,对你工作中写文档会非常有帮助)
\n句子不要过长,尽量使用短句(但也不要太短),这样读者更容易阅读和理解。
\n尽量让文章更加生动有趣,比如你可以适当举一些形象的例子、用一些有趣的段子、歇后语或者网络热词。
\n不过,这个也主要看你的文章风格。
\n避免使用阅读者可能无法理解的行话或复杂语言。
\n注重清晰度和说服力,保持简单。简单的写作是有说服力的,一个五句话的好论点会比一百句话的精彩论点更能打动人。为什么格言、箴言这类文字容易让人接受,与简洁、直白也有些关系。
\n图表、图像等视觉效果可以让朴素的文本内容更容易理解。记得在适当的地方使用视觉效果来增强你的文章的表现力。
\n\n下面是同样内容的两张图,都是通过 drawio 画的,小伙伴们更喜欢哪一张呢?
\n我相信大部分小伙伴都会选择后面一个色彩更鲜明的!
\n色彩的调整不过花费了我不到 30s 的时间,带来的阅读体验的上升却是非常之大!
\n\n写作之前,思考一下你的文章的主要受众全体是谁。受众群体确定之后,你可以根据受众的需求和理解水平调整你的写作风格和内容难易程度。
\n在发表之前一定要审查和修改你的文章。这将帮助你发现错误、澄清任何令人困惑的信息并提高文档的整体质量。
\n好文是改出来的,切记!!!
\n总的来说,写技术博客是一件利己利彼的事情。你可能会从中收获到很多东西,你写的东西也可能对别人也有很大的帮助。但是,写技术博客还是比较耗费自己时间的,你需要和工作以及生活做好权衡。
\n", "image": "https://oss.javaguide.cn/about-the-author/college-life/image-20230408131717766.png", "date_published": "2023-04-09T12:42:14.000Z", "date_modified": "2023-05-05T04:49:01.000Z", "authors": [], "tags": [ "走近作者" ] }, { "title": "操作系统常见面试题总结(下)", "url": "https://javaguide.cn/cs-basics/operating-system/operating-system-basic-questions-02.html", "id": "https://javaguide.cn/cs-basics/operating-system/operating-system-basic-questions-02.html", "summary": "内存管理 内存管理主要做了什么? 内存管理主要做的事情内存管理主要做的事情 操作系统的内存管理非常重要,主要负责下面这些事情: 内存的分配与回收:对进程所需的内存进行分配和释放,malloc 函数:申请内存,free 函数:释放内存。 地址转换:将程序中的虚拟地址转换成内存中的物理地址。 内存扩充:当系统没有足够的内存时,利用虚拟内存技术或自动覆盖技术...", "content_html": "操作系统的内存管理非常重要,主要负责下面这些事情:
\n内存碎片是由内存的申请和释放产生的,通常分为下面两种:
\n内存碎片会导致内存利用率下降,如何减少内存碎片是内存管理要非常重视的一件事情。
\n内存管理方式可以简单分为下面两种:
\n块式管理 是早期计算机操作系统的一种连续内存管理方式,存在严重的内存碎片问题。块式管理会将内存分为几个固定大小的块,每个块中只包含一个进程。如果程序运行需要内存的话,操作系统就分配给它一块,如果程序运行只需要很小的空间的话,分配的这块内存很大一部分几乎被浪费了。这些在每个块中未被利用的空间,我们称之为内部内存碎片。除了内部内存碎片之外,由于两个内存块之间可能还会有外部内存碎片,这些不连续的外部内存碎片由于太小了无法再进行分配。
\n在 Linux 系统中,连续内存管理采用了 伙伴系统(Buddy System)算法 来实现,这是一种经典的连续内存分配算法,可以有效解决外部内存碎片的问题。伙伴系统的主要思想是将内存按 2 的幂次划分(每一块内存大小都是 2 的幂次比如 2^6=64 KB),并将相邻的内存块组合成一对伙伴(注意:必须是相邻的才是伙伴)。
\n当进行内存分配时,伙伴系统会尝试找到大小最合适的内存块。如果找到的内存块过大,就将其一分为二,分成两个大小相等的伙伴块。如果还是大的话,就继续切分,直到到达合适的大小为止。
\n假设两块相邻的内存块都被释放,系统会将这两个内存块合并,进而形成一个更大的内存块,以便后续的内存分配。这样就可以减少内存碎片的问题,提高内存利用率。
\n\n虽然解决了外部内存碎片的问题,但伙伴系统仍然存在内存利用率不高的问题(内部内存碎片)。这主要是因为伙伴系统只能分配大小为 2^n 的内存块,因此当需要分配的内存大小不是 2^n 的整数倍时,会浪费一定的内存空间。举个例子:如果要分配 65 大小的内存快,依然需要分配 2^7=128 大小的内存块。
\n\n对于内部内存碎片的问题,Linux 采用 SLAB 进行解决。由于这部分内容不是本篇文章的重点,这里就不详细介绍了。
\n非连续内存管理存在下面 3 种方式:
\n虚拟内存(Virtual Memory) 是计算机系统内存管理非常重要的一个技术,本质上来说它只是逻辑存在的,是一个假想出来的内存空间,主要作用是作为进程访问主存(物理内存)的桥梁并简化内存管理。
\n\n总结来说,虚拟内存主要提供了下面这些能力:
\n如果没有虚拟内存的话,程序直接访问和操作的都是物理内存,看似少了一层中介,但多了很多问题。
\n具体有什么问题呢? 这里举几个例子说明(参考虚拟内存提供的能力回答这个问题):
\n物理地址(Physical Address) 是真正的物理内存中地址,更具体点来说是内存地址寄存器中的地址。程序中访问的内存地址不是物理地址,而是 虚拟地址(Virtual Address) 。
\n也就是说,我们编程开发的时候实际就是在和虚拟地址打交道。比如在 C 语言中,指针里面存储的数值就可以理解成为内存里的一个地址,这个地址也就是我们说的虚拟地址。
\n操作系统一般通过 CPU 芯片中的一个重要组件 MMU(Memory Management Unit,内存管理单元) 将虚拟地址转换为物理地址,这个过程被称为 地址翻译/地址转换(Address Translation) 。
\n\n通过 MMU 将虚拟地址转换为物理地址后,再通过总线传到物理内存设备,进而完成相应的物理内存读写请求。
\nMMU 将虚拟地址翻译为物理地址的主要机制有两种: 分段机制 和 分页机制 。
\nMMU 将虚拟地址翻译为物理地址的主要机制有 3 种:
\n其中,现代操作系统广泛采用分页机制,需要重点关注!
\n分段机制(Segmentation) 以段(—段 连续 的物理内存)的形式管理/分配物理内存。应用程序的虚拟地址空间被分为大小不等的段,段是有实际意义的,每个段定义了一组逻辑信息,例如有主程序段 MAIN、子程序段 X、数据段 D 及栈段 S 等。
\n分段管理通过 段表(Segment Table) 映射虚拟地址和物理地址。
\n分段机制下的虚拟地址由两部分组成:
\n具体的地址翻译过程如下:
\n段表中还存有诸如段长(可用于检查虚拟地址是否超出合法范围)、段类型(该段的类型,例如代码段、数据段等)等信息。
\n通过段号一定要找到对应的段表项吗?得到最终的物理地址后对应的物理内存一定存在吗?
\n不一定。段表项可能并不存在:
\n分段机制容易出现外部内存碎片,即在段与段之间留下碎片空间(不足以映射给虚拟地址空间中的段)。从而造成物理内存资源利用率的降低。
\n举个例子:假设可用物理内存为 5G 的系统使用分段机制分配内存。现在有 4 个进程,每个进程的内存占用情况如下:
\n此时,我们关闭了进程 1 和进程 4,则第 1 段和第 4 段的内存会被释放,空闲物理内存还有 1.5G。由于这 1.5G 物理内存并不是连续的,导致没办法将空闲的物理内存分配给一个需要 1.5G 物理内存的进程。
\n\n分页机制(Paging) 把主存(物理内存)分为连续等长的物理页,应用程序的虚拟地址空间划也被分为连续等长的虚拟页。现代操作系统广泛采用分页机制。
\n注意:这里的页是连续等长的,不同于分段机制下不同长度的段。
\n在分页机制下,应用程序虚拟地址空间中的任意虚拟页可以被映射到物理内存中的任意物理页上,因此可以实现物理内存资源的离散分配。分页机制按照固定页大小分配物理内存,使得物理内存资源易于管理,可有效避免分段机制中外部内存碎片的问题。
\n分页管理通过 页表(Page Table) 映射虚拟地址和物理地址。我这里画了一张基于单级页表进行地址翻译的示意图。
\n\n在分页机制下,每个应用程序都会有一个对应的页表。
\n分页机制下的虚拟地址由两部分组成:
\n具体的地址翻译过程如下:
\n页表中还存有诸如访问标志(标识该页面有没有被访问过)、脏数据标识位等信息。
\n通过虚拟页号一定要找到对应的物理页号吗?找到了物理页号得到最终的物理地址后对应的物理页一定存在吗?
\n不一定!可能会存在 页缺失 。也就是说,物理内存中没有对应的物理页或者物理内存中有对应的物理页但虚拟页还未和物理页建立映射(对应的页表项不存在)。关于页缺失的内容,后面会详细介绍到。
\n以 32 位的环境为例,虚拟地址空间范围共有 2^32(4G)。假设 一个页的大小是 2^12(4KB),那页表项共有 4G / 4K = 2^20 个。每个页表项为一个地址,占用 4 字节,2^20 * 2^2 / 1024 * 1024= 4MB
。也就是说一个程序啥都不干,页表大小就得占用 4M。
系统运行的应用程序多起来的话,页表的开销还是非常大的。而且,绝大部分应用程序可能只能用到页表中的几项,其他的白白浪费了。
\n为了解决这个问题,操作系统引入了 多级页表 ,多级页表对应多个页表,每个页表也前一个页表相关联。32 位系统一般为二级页表,64 位系统一般为四级页表。
\n这里以二级页表为例进行介绍:二级列表分为一级页表和二级页表。一级页表共有 1024 个页表项,一级页表又关联二级页表,二级页表同样共有 1024 个页表项。二级页表中的一级页表项是一对多的关系,二级页表按需加载(只会用到很少一部分二级页表),进而节省空间占用。
\n假设只需要 2 个二级页表,那两级页表的内存占用情况为: 4KB(一级页表占用) + 4KB * 2(二级页表占用) = 12 KB。
\n\n多级页表属于时间换空间的典型场景,利用增加页表查询的次数减少页表占用的空间。
\n为了提高虚拟地址到物理地址的转换速度,操作系统在 页表方案 基础之上引入了 转址旁路缓存(Translation Lookaside Buffer,TLB,也被称为快表) 。
\n\n在主流的 AArch64 和 x86-64 体系结构下,TLB 属于 (Memory Management Unit,内存管理单元) 内部的单元,本质上就是一块高速缓存(Cache),缓存了虚拟页号到物理页号的映射关系,你可以将其简单看作是存储着键(虚拟页号)值(物理页号)对的哈希表。
\n使用 TLB 之后的地址翻译流程是这样的:
\n由于页表也在主存中,因此在没有 TLB 之前,每次读写内存数据时 CPU 要访问两次主存。有了 TLB 之后,对于存在于 TLB 中的页表数据只需要访问一次主存即可。
\nTLB 的设计思想非常简单,但命中率往往非常高,效果很好。这就是因为被频繁访问的页就是其中的很小一部分。
\n看完了之后你会发现快表和我们平时经常在开发系统中使用的缓存(比如 Redis)很像,的确是这样的,操作系统中的很多思想、很多经典的算法,你都可以在我们日常开发使用的各种工具或者框架中找到它们的影子。
\n换页机制的思想是当物理内存不够用的时候,操作系统选择将一些物理页的内容放到磁盘上去,等要用到的时候再将它们读取到物理内存中。也就是说,换页机制利用磁盘这种较低廉的存储设备扩展的物理内存。
\n这也就解释了一个日常使用电脑常见的问题:为什么操作系统中所有进程运行所需的物理内存即使比真实的物理内存要大一些,这些进程也是可以正常运行的,只是运行速度会变慢。
\n这同样是一种时间换空间的策略,你用 CPU 的计算时间,页的调入调出花费的时间,换来了一个虚拟的更大的物理内存空间来支持程序的运行。
\n根据维基百科:
\n\n\n页缺失(Page Fault,又名硬错误、硬中断、分页错误、寻页缺失、缺页中断、页故障等)指的是当软件试图访问已映射在虚拟地址空间中,但是目前并未被加载在物理内存中的一个分页时,由 MMU 所发出的中断。
\n
常见的页缺失有下面这两种:
\n发生上面这两种缺页错误的时候,应用程序访问的是有效的物理内存,只是出现了物理页缺失或者虚拟页和物理页的映射关系未建立的问题。如果应用程序访问的是无效的物理内存的话,还会出现 无效缺页错误(Invalid Page Fault) 。
\n当发生硬性页缺失时,如果物理内存中没有空闲的物理页面可用的话。操作系统就必须将物理内存中的一个物理页淘汰出去,这样就可以腾出空间来加载新的页面了。
\n用来选择淘汰哪一个物理页的规则叫做 页面置换算法 ,我们可以把页面置换算法看成是淘汰物物理页的规则。
\n页缺失太频繁的发生会非常影响性能,一个好的页面置换算法应该是可以减少页缺失出现的次数。
\n常见的页面置换算法有下面这 5 种(其他还有很多页面置换算法都是基于这些算法改进得来的):
\n\nFIFO 页面置换算法性能为何不好?
\n主要原因主要有二:
\n哪一种页面置换算法实际用的比较多?
\nLRU 算法是实际使用中应用的比较多,也被认为是最接近 OPT 的页面置换算法。
\n不过,需要注意的是,实际应用中这些算法会被做一些改进,就比如 InnoDB Buffer Pool( InnoDB 缓冲池,MySQL 数据库中用于管理缓存页面的机制)就改进了传统的 LRU 算法,使用了一种称为\"Adaptive LRU\"的算法(同时结合了 LRU 和 LFU 算法的思想)。
\n共同点:
\n区别:
\n结合了段式管理和页式管理的一种内存管理机制,把物理内存先分成若干段,每个段又继续分成若干大小相等的页。
\n在段页式机制下,地址翻译的过程分为两个步骤:
\n要想更好地理解虚拟内存技术,必须要知道计算机中著名的 局部性原理(Locality Principle)。另外,局部性原理既适用于程序结构,也适用于数据结构,是非常重要的一个概念。
\n局部性原理是指在程序执行过程中,数据和指令的访问存在一定的空间和时间上的局部性特点。其中,时间局部性是指一个数据项或指令在一段时间内被反复使用的特点,空间局部性是指一个数据项或指令在一段时间内与其相邻的数据项或指令被反复使用的特点。
\n在分页机制中,页表的作用是将虚拟地址转换为物理地址,从而完成内存访问。在这个过程中,局部性原理的作用体现在两个方面:
\n总之,局部性原理是计算机体系结构设计的重要原则之一,也是许多优化算法的基础。在分页机制中,利用时间局部性和空间局部性,采用缓存和预取技术,可以提高页面的命中率,从而提高内存访问效率
\n文件系统主要负责管理和组织计算机存储设备上的文件和目录,其功能包括以下几个方面:
\n在 Linux/类 Unix 系统上,文件链接(File Link)是一种特殊的文件类型,可以在文件系统中指向另一个文件。常见的文件链接类型有两种:
\n1、硬链接(Hard Link)
\nln
命令用于创建硬链接。2、软链接(Symbolic Link 或 Symlink)
\nln -s
命令用于创建软链接。我们之前提到过,硬链接是通过 inode 节点号建立连接的,而硬链接和源文件共享相同的 inode 节点号。
\n然而,每个文件系统都有自己的独立 inode 表,且每个 inode 表只维护该文件系统内的 inode。如果在不同的文件系统之间创建硬链接,可能会导致 inode 节点号冲突的问题,即目标文件的 inode 节点号已经在该文件系统中被使用。
\n磁盘调度算法是操作系统中对磁盘访问请求进行排序和调度的算法,其目的是提高磁盘的访问效率。
\n一次磁盘读写操作的时间由磁盘寻道/寻找时间、延迟时间和传输时间决定。磁盘调度算法可以通过改变到达磁盘请求的处理顺序,减少磁盘寻道时间和延迟时间。
\n常见的磁盘调度算法有下面这 6 种(其他还有很多磁盘调度算法都是基于这些算法改进得来的):
\n\nJDK 20 于 2023 年 3 月 21 日发布,非长期支持版本。
\n根据开发计划,下一个 LTS 版本就是将于 2023 年 9 月发布的 JDK 21。
\n\nJDK 20 只有 7 个新特性:
\n作用域值(Scoped Values)它可以在线程内和线程间共享不可变的数据,优于线程局部变量,尤其是在使用大量虚拟线程时。
\nfinal static ScopedValue<...> V = new ScopedValue<>();\n\n// In some method\nScopedValue.where(V, <value>)\n .run(() -> { ... V.get() ... call methods ... });\n\n// In a method called directly or indirectly from the lambda expression\n... V.get() ...\n
作用域值允许在大型程序中的组件之间安全有效地共享数据,而无需求助于方法参数。
\n关于作用域值的详细介绍,推荐阅读作用域值常见问题解答这篇文章。
\n记录模式(Record Patterns) 可对 record 的值进行解构,也就是更方便地从记录类(Record Class)中提取数据。并且,还可以嵌套记录模式和类型模式结合使用,以实现强大的、声明性的和可组合的数据导航和处理形式。
\n记录模式不能单独使用,而是要与 instanceof 或 switch 模式匹配一同使用。
\n先以 instanceof 为例简单演示一下。
\n简单定义一个记录类:
\nrecord Shape(String type, long unit){}\n
没有记录模式之前:
\nShape circle = new Shape(\"Circle\", 10);\nif (circle instanceof Shape shape) {\n\n System.out.println(\"Area of \" + shape.type() + \" is : \" + Math.PI * Math.pow(shape.unit(), 2));\n}\n
有了记录模式之后:
\nShape circle = new Shape(\"Circle\", 10);\nif (circle instanceof Shape(String type, long unit)) {\n System.out.println(\"Area of \" + type + \" is : \" + Math.PI * Math.pow(unit, 2));\n}\n
再看看记录模式与 switch 的配合使用。
\n定义一些类:
\ninterface Shape {}\nrecord Circle(double radius) implements Shape { }\nrecord Square(double side) implements Shape { }\nrecord Rectangle(double length, double width) implements Shape { }\n
没有记录模式之前:
\nShape shape = new Circle(10);\nswitch (shape) {\n case Circle c:\n System.out.println(\"The shape is Circle with area: \" + Math.PI * c.radius() * c.radius());\n break;\n\n case Square s:\n System.out.println(\"The shape is Square with area: \" + s.side() * s.side());\n break;\n\n case Rectangle r:\n System.out.println(\"The shape is Rectangle with area: + \" + r.length() * r.width());\n break;\n\n default:\n System.out.println(\"Unknown Shape\");\n break;\n}\n
有了记录模式之后:
\nShape shape = new Circle(10);\nswitch(shape) {\n\n case Circle(double radius):\n System.out.println(\"The shape is Circle with area: \" + Math.PI * radius * radius);\n break;\n\n case Square(double side):\n System.out.println(\"The shape is Square with area: \" + side * side);\n break;\n\n case Rectangle(double length, double width):\n System.out.println(\"The shape is Rectangle with area: + \" + length * width);\n break;\n\n default:\n System.out.println(\"Unknown Shape\");\n break;\n}\n
记录模式可以避免不必要的转换,使得代码更建简洁易读。而且,用了记录模式后不必再担心 null
或者 NullPointerException
,代码更安全可靠。
记录模式在 Java 19 进行了第一次预览, 由 JEP 405 提出。JDK 20 中是第二次预览,由 JEP 432 提出。这次的改进包括:
\nfor
注意:不要把记录模式和 JDK16 正式引入的记录类搞混了。
\n正如 instanceof
一样, switch
也紧跟着增加了类型匹配自动转换功能。
instanceof
代码示例:
// Old code\nif (o instanceof String) {\n String s = (String)o;\n ... use s ...\n}\n\n// New code\nif (o instanceof String s) {\n ... use s ...\n}\n
switch
代码示例:
// Old code\nstatic String formatter(Object o) {\n String formatted = \"unknown\";\n if (o instanceof Integer i) {\n formatted = String.format(\"int %d\", i);\n } else if (o instanceof Long l) {\n formatted = String.format(\"long %d\", l);\n } else if (o instanceof Double d) {\n formatted = String.format(\"double %f\", d);\n } else if (o instanceof String s) {\n formatted = String.format(\"String %s\", s);\n }\n return formatted;\n}\n\n// New code\nstatic String formatterPatternSwitch(Object o) {\n return switch (o) {\n case Integer i -> String.format(\"int %d\", i);\n case Long l -> String.format(\"long %d\", l);\n case Double d -> String.format(\"double %f\", d);\n case String s -> String.format(\"String %s\", s);\n default -> o.toString();\n };\n}\n
switch
模式匹配分别在 Java17、Java18、Java19 中进行了预览,Java20 是第四次预览了。每一次的预览基本都会有一些小改进,这里就不细提了。
Java 程序可以通过该 API 与 Java 运行时之外的代码和数据进行互操作。通过高效地调用外部函数(即 JVM 之外的代码)和安全地访问外部内存(即不受 JVM 管理的内存),该 API 使 Java 程序能够调用本机库并处理本机数据,而不会像 JNI 那样危险和脆弱。
\n外部函数和内存 API 在 Java 17 中进行了第一轮孵化,由 JEP 412 提出。Java 18 中进行了第二次孵化,由JEP 419 提出。Java 19 中是第一次预览,由 JEP 424 提出。
\nJDK 20 中是第二次预览,由 JEP 434 提出,这次的改进包括:
\nMemorySegment
和 MemoryAddress
抽象的统一MemoryLayout
层次结构MemorySession
拆分为Arena
和SegmentScope
,以促进跨维护边界的段共享。在 Java 19 新特性概览 中,我有详细介绍到外部函数和内存 API,这里就不再做额外的介绍了。
\n虚拟线程(Virtual Thread)是 JDK 而不是 OS 实现的轻量级线程(Lightweight Process,LWP),由 JVM 调度。许多虚拟线程共享同一个操作系统线程,虚拟线程的数量可以远大于操作系统线程的数量。
\n在引入虚拟线程之前,java.lang.Thread
包已经支持所谓的平台线程,也就是没有虚拟线程之前,我们一直使用的线程。JVM 调度程序通过平台线程(载体线程)来管理虚拟线程,一个平台线程可以在不同的时间执行不同的虚拟线程(多个虚拟线程挂载在一个平台线程上),当虚拟线程被阻塞或等待时,平台线程可以切换到执行另一个虚拟线程。
虚拟线程、平台线程和系统内核线程的关系图如下所示(图源:How to Use Java 19 Virtual Threads):
\n\n关于平台线程和系统内核线程的对应关系多提一点:在 Windows 和 Linux 等主流操作系统中,Java 线程采用的是一对一的线程模型,也就是一个平台线程对应一个系统内核线程。Solaris 系统是一个特例,HotSpot VM 在 Solaris 上支持多对多和一对一。具体可以参考 R 大的回答: JVM 中的线程模型是用户级的么?。
\n相比较于平台线程来说,虚拟线程是廉价且轻量级的,使用完后立即被销毁,因此它们不需要被重用或池化,每个任务可以有自己专属的虚拟线程来运行。虚拟线程暂停和恢复来实现线程之间的切换,避免了上下文切换的额外耗费,兼顾了多线程的优点,简化了高并发程序的复杂,可以有效减少编写、维护和观察高吞吐量并发应用程序的工作量。
\n虚拟线程在其他多线程语言中已经被证实是十分有用的,比如 Go 中的 Goroutine、Erlang 中的进程。
\n知乎有一个关于 Java 19 虚拟线程的讨论,感兴趣的可以去看看:https://www.zhihu.com/question/536743167 。
\nJava 虚拟线程的详细解读和原理可以看下面这几篇文章:
\n\n虚拟线程在 Java 19 中进行了第一次预览,由JEP 425提出。JDK 20 中是第二次预览,做了一些细微变化,这里就不细提了。
\n最后,我们来看一下四种创建虚拟线程的方法:
\n// 1、通过 Thread.ofVirtual() 创建\nRunnable fn = () -> {\n // your code here\n};\n\nThread thread = Thread.ofVirtual(fn)\n .start();\n\n// 2、通过 Thread.startVirtualThread() 、创建\nThread thread = Thread.startVirtualThread(() -> {\n // your code here\n});\n\n// 3、通过 Executors.newVirtualThreadPerTaskExecutor() 创建\nvar executorService = Executors.newVirtualThreadPerTaskExecutor();\n\nexecutorService.submit(() -> {\n // your code here\n});\n\nclass CustomThread implements Runnable {\n @Override\n public void run() {\n System.out.println(\"CustomThread run\");\n }\n}\n\n//4、通过 ThreadFactory 创建\nCustomThread customThread = new CustomThread();\n// 获取线程工厂类\nThreadFactory factory = Thread.ofVirtual().factory();\n// 创建虚拟线程\nThread thread = factory.newThread(customThread);\n// 启动线程\nthread.start();\n
通过上述列举的 4 种创建虚拟线程的方式可以看出,官方为了降低虚拟线程的门槛,尽力复用原有的 Thread
线程类,这样可以平滑的过渡到虚拟线程的使用。
Java 19 引入了结构化并发,一种多线程编程方法,目的是为了通过结构化并发 API 来简化多线程编程,并不是为了取代java.util.concurrent
,目前处于孵化器阶段。
结构化并发将不同线程中运行的多个任务视为单个工作单元,从而简化错误处理、提高可靠性并增强可观察性。也就是说,结构化并发保留了单线程代码的可读性、可维护性和可观察性。
\n结构化并发的基本 API 是StructuredTaskScope
。StructuredTaskScope
支持将任务拆分为多个并发子任务,在它们自己的线程中执行,并且子任务必须在主任务继续之前完成。
StructuredTaskScope
的基本用法如下:
try (var scope = new StructuredTaskScope<Object>()) {\n // 使用fork方法派生线程来执行子任务\n Future<Integer> future1 = scope.fork(task1);\n Future<String> future2 = scope.fork(task2);\n // 等待线程完成\n scope.join();\n // 结果的处理可能包括处理或重新抛出异常\n ... process results/exceptions ...\n } // close\n
结构化并发非常适合虚拟线程,虚拟线程是 JDK 实现的轻量级线程。许多虚拟线程共享同一个操作系统线程,从而允许非常多的虚拟线程。
\nJDK 20 中对结构化并发唯一变化是更新为支持在任务范围内创建的线程StructuredTaskScope
继承范围值 这简化了跨线程共享不可变数据,详见JEP 429。
向量计算由对向量的一系列操作组成。向量 API 用来表达向量计算,该计算可以在运行时可靠地编译为支持的 CPU 架构上的最佳向量指令,从而实现优于等效标量计算的性能。
\n向量 API 的目标是为用户提供简洁易用且与平台无关的表达范围广泛的向量计算。
\n向量(Vector) API 最初由 JEP 338 提出,并作为孵化 API集成到 Java 16 中。第二轮孵化由 JEP 414 提出并集成到 Java 17 中,第三轮孵化由 JEP 417 提出并集成到 Java 18 中,第四轮由 JEP 426 提出并集成到了 Java 19 中。
\nJava20 的这次孵化基本没有改变向量 API ,只是进行了一些错误修复和性能增强,详见 JEP 438。
\n\n", "image": "https://oss.javaguide.cn/github/javaguide/java/new-features/640.png", "date_published": "2023-03-27T07:25:00.000Z", "date_modified": "2023-10-30T05:32:52.000Z", "authors": [], "tags": [ "Java" ] }, { "title": "从校招入职腾讯的四年工作总结", "url": "https://javaguide.cn/high-quality-technical-articles/personal-experience/four-year-work-in-tencent-summary.html", "id": "https://javaguide.cn/high-quality-technical-articles/personal-experience/four-year-work-in-tencent-summary.html", "summary": "程序员是一个流动性很大的职业,经常会有新面孔的到来,也经常会有老面孔的离开,有主动离开的,也有被动离职的。 再加上这几年卷得厉害,做的事更多了,拿到的却更少了,互联网好像也没有那么香了。 人来人往,变动无常的状态,其实也早已习惯。 打工人的唯一出路,无外乎精进自己的专业技能,提升自己的核心竞争力,这样无论有什么变动,走到哪里,都能有口饭吃。 今天分享一...", "content_html": "程序员是一个流动性很大的职业,经常会有新面孔的到来,也经常会有老面孔的离开,有主动离开的,也有被动离职的。
\n再加上这几年卷得厉害,做的事更多了,拿到的却更少了,互联网好像也没有那么香了。
\n人来人往,变动无常的状态,其实也早已习惯。
\n打工人的唯一出路,无外乎精进自己的专业技能,提升自己的核心竞争力,这样无论有什么变动,走到哪里,都能有口饭吃。
\n今天分享一位博主,校招入职腾讯,工作四年后,离开的故事。
\n至于为什么离开,我也不清楚,可能是有其他更好的选择,或者是觉得当前的工作对自己的提升有限。
\n下文中的“我”,指这位作者本人。
\n\n\n\n
研究生毕业后, 一直在腾讯工作,不知不觉就过了四年。个人本身没有刻意总结的习惯,以前只顾着往前奔跑了,忘了停下来思考总结。记得看过一个职业规划文档,说的三年一个阶段,五年一个阶段的说法,现在恰巧是四年,同时又从腾讯离开,该做一个总结了。
\n先对自己这四年做一个简单的评价吧:个人认为,没有完全的浪费和辜负这四年的光阴。为何要这么说了?因为我发现和别人对比,好像意义不大,比我混的好的人很多;比我混的差的人也不少。说到底,我只是一个普普通通的人,才不惊人,技不压众,接受自己的平凡,然后看自己做的,是否让自己满意就好。
\n下面具体谈几点吧,我主要想聊下工作,绩效,EPC,嫡系看法,最后再谈下收获。
\n我在腾讯内部没有转过岗,但是做过的项目也还是比较丰富的,包括:BUGLY、分布式调用链(Huskie)、众包系统(SOHO),EPC 度量系统。其中一些是对外的,一些是内部系统,可能有些大家不知道。还是比较感谢这些项目经历,既有纯业务的系统,也有偏框架的系统,让我学到了不少知识。
\n接下来,简单介绍一下每个项目吧,毕竟每一个项目都付出了很多心血的:
\nBUGLY,这是一个终端 Crash 联网上报的系统,很多 APP 都接入了。Huskie,这是一个基于 zipkin 搭建的分布式调用链跟踪项目。SOHO,这是一个众包系统,主要是将数据标准和语音采集任务众包出去,让人家做。EPC 度量系统,这是研发效能度量系统,主要是度量研发效能情况的。这里我谈一下对于业务开发的理解和认识,很多人可能都跟我最开始一样,有一个疑惑,整天做业务开发如何成长?换句话说,就是说整天做 CRUD,如何成长?我开始也有这样的疑惑,后来我转变了观念。
\n我觉得对于系统的复杂度,可以粗略的分为技术复杂度和业务复杂度,对于业务系统,就是业务复杂度高一些,对于框架系统就是技术复杂度偏高一些。解决这两种复杂度,都具有很大的挑战。
\n此前做过的众包系统,就是各种业务逻辑,搞过去,搞过来,其实这就是业务复杂度高。为了解决这个问题,我们开始探索和实践领域驱动(DDD),确实带来了一些帮助,不至于系统那么混乱了。同时,我觉得这个过程中,自己对于 DDD 的感悟,对于我后来的项目系统划分和设计以及开发都带来了帮助。
\n当然 DDD 不是银弹,我也不是吹嘘它有多好,只是了解了它后,有时候设计和开发时,能换一种思路。
\n可以发现,其实平时咱们做业务,想做好,其实也没那么容易,如果可以多探索多实践,将一些好的方法或思想或架构引入进来,与个人和业务都会有有帮助。
\n我在腾讯工作四年,腾讯半年考核一次,一共考核八次,回想了下,四年来的绩效情况为:三星,三星,五星,三星,五星,四星,四星,三星。统计一下, 四五星占比刚好一半。
\n\nPS:还好以前有奖杯,不然一点念想都没了。(现在腾讯似乎不发了)
\n印象比较深的是两次五星获得经历。第一次五星是工作的第二年,那一年是在做众包项目,因为项目本身难度不大,因此我把一些精力投入到了团队的基础建设中,帮团队搭建了 java 以及 golang 的项目脚手架,又做了几次中心技术分享,最终 Leader 觉得我表现比较突出,因此给了我五星。看来,主动一些,与个人与团队都是有好处的,最终也能获得一些回报。
\n第二次五星,就是与 EPC 有关了。说一个搞笑的事,我也是后来才知道的,项目初期,总监去汇报时,给老板演示系统,加载了很久指标才刷出来,总监很不好意思的说正在优化;过了一段时间,又去汇报演示,结果又很尴尬的刷了很久才出来,总监无赖表示还是在优化。没想到,自己曾经让总监这么丢脸,哈哈。好吧,说一下结果,最终,我自己写了一个查询引擎替换了 Mondrian,之后再也没有出现那种尴尬的情况了。随之而来,也给了好绩效鼓励。做 EPC 度量项目,我觉得自己成长很大,比如抗压能力,当你从零到一搭建一个系统时,会有一个先扛住再优化的过程,此外如果你的项目很重要,尤其是数据相关,那么任何一点问题,都可能让你神经紧绷,得想尽办法降低风险和故障。此外,另一个不同的感受就是,以前得项目,我大多是开发者,而这个系统,我是 Owner 负责人,当你 Owner 一个系统时,你得时刻负责,同时还需要思考系统的规划和方向,此外还需要分配好需求和把控进度,角色体验跟以前完全不一样。
\n很多人都骂 EPC,或者笑 EPC,作为度量平台核心开发者之一,我来谈谈客观的看法。
\n其实 EPC 初衷是好的,希望通过全方位多维度的研效指标,来度量研发效能各环节的质量,进而反推业务,提升研发效能。然而,最终在实践的过程中,才发现,客观条件并不支持(工具还没建设好);此外,一味的追求指标数据,使得下面的人想方设法让指标好看,最终违背了初衷。
\n为什么,说 EPC 好了,其实如果你仔细了解下 EPC,你就会发现,他是一套相当完善且比较先进的指标度量体系。覆盖了需求,代码,缺陷,测试,持续集成,运营部署各个环节。
\n此外,这个过程中,虽然一些人和一些业务做弊,但绝大多数业务还是做出了改变的,比如微视那边的人反馈是,以前的代码写的跟屎一样,当有了 EPC 后,代码质量好了很多。虽然最后微视还是亡了,但是大厦将倾,EPC 是救不了的,亡了也更不能怪 EPC。
\n大家都说腾讯,嫡系文化盛行。但其实我觉得在那个公司都一样吧。这也符合事物的基本规律,人们只相信自己信任并熟悉的人。作为领导,你难道会把把重要的事情交给自己不熟悉的人吗?
\n其实我也不知道我算不算嫡系,脉脉上有人问过”怎么知道自己算不算嫡系”,下面有一个回答,我觉得很妙:如果你不知道你是不是嫡系,那你就不是。哈哈,这么说来,我可能不是。
\n但另一方面,后来我负责了团队内很重要的事情,应该是中心内都算很重要的事,我独自负责一个方向,直接向总监汇报,似乎又有点像。
\n网上也有其他说法,一针见血,是不是嫡系,就看钱到不到位,这么说也有道理。我在 7 级时,就发了股票,自我感觉,还是不错的。我当时以为不出意外的话,我以后的钱途和发展是不是就会一帆风顺。不出意外就出了意外,第二年,EPC 不达预期,部门总经理和总监都被换了,中心来了一个新的的总监。
\n好吧,又要重新建立信任了。再到后来,是不是嫡系已经不重要了,因为大环境不好,又加上裁员,大家主动的被动的差不多都走了。
\n总结一下,嫡系的存在,其实情有可原。怎么样成为嫡系了?其实我也不知道。不过,我觉得,与其思考怎么成为嫡系,不如思考怎么展现自己的价值和能力,当别人发现你的价值和能力了,那自然更多的机会就会给予你,有了机会,只要把握住了,那就有更多的福利了。
\n收获,什么叫做收获了?个人觉得无论是外在的物质,技能,职级;还是内在的感悟,认识,都算收获。
\n先说一些可量化的吧,我觉得有:
\n1、文档能力
\n作为程序员,文档能力其实是一项很重要的能力。其实我也没觉得自己文档能力有多好,但是前后两任总监,都说我的文档不错,那看来,我可能在平均水准之上。
\n2、明确方向
\n最后,说一个更虚的,但是我觉得最有价值的收获: 我逐渐明确了,或者确定了以后的方向和路,那就是走数据开发。
\n其实,找到并确定一个目标很难,身边有清晰目标和方向的人很少,大多数是迷茫的。
\n前一段时间,跟人聊天,谈到职业规划,说是可以从两个角度思考:
\n腾讯的四年,是我的第一份工作经历,认识了很多厉害的人,学到了很多。最后自己主动离开,也算走的体面(即使损失了大礼包),还是感谢腾讯。
\n\n", "image": "https://oss.javaguide.cn/github/javaguide/high-quality-technical-articles/640.png", "date_published": "2023-03-24T11:23:46.000Z", "date_modified": "2023-12-30T09:14:13.000Z", "authors": [ { "name": "pioneeryi" } ], "tags": [ "技术文章精选集" ] }, { "title": "Redis持久化机制详解", "url": "https://javaguide.cn/database/redis/redis-persistence.html", "id": "https://javaguide.cn/database/redis/redis-persistence.html", "summary": "使用缓存的时候,我们经常需要对内存中的数据进行持久化也就是将内存中的数据写入到硬盘中。大部分原因是为了之后重用数据(比如重启机器、机器故障之后恢复数据),或者是为了做数据同步(比如 Redis 集群的主从节点通过 RDB 文件同步数据)。 Redis 不同于 Memcached 的很重要一点就是,Redis 支持持久化,而且支持 3 种持久化方式: 快...", "content_html": "使用缓存的时候,我们经常需要对内存中的数据进行持久化也就是将内存中的数据写入到硬盘中。大部分原因是为了之后重用数据(比如重启机器、机器故障之后恢复数据),或者是为了做数据同步(比如 Redis 集群的主从节点通过 RDB 文件同步数据)。
\nRedis 不同于 Memcached 的很重要一点就是,Redis 支持持久化,而且支持 3 种持久化方式:
\n官方文档地址:https://redis.io/topics/persistence 。
\n\nRedis 可以通过创建快照来获得存储在内存里面的数据在 某个时间点 上的副本。Redis 创建快照之后,可以对快照进行备份,可以将快照复制到其他服务器从而创建具有相同数据的服务器副本(Redis 主从结构,主要用来提高 Redis 性能),还可以将快照留在原地以便重启服务器的时候使用。
\n快照持久化是 Redis 默认采用的持久化方式,在 redis.conf
配置文件中默认有此下配置:
save 900 1 #在900秒(15分钟)之后,如果至少有1个key发生变化,Redis就会自动触发bgsave命令创建快照。\n\nsave 300 10 #在300秒(5分钟)之后,如果至少有10个key发生变化,Redis就会自动触发bgsave命令创建快照。\n\nsave 60 10000 #在60秒(1分钟)之后,如果至少有10000个key发生变化,Redis就会自动触发bgsave命令创建快照。\n
Redis 提供了两个命令来生成 RDB 快照文件:
\nsave
: 同步保存操作,会阻塞 Redis 主线程;bgsave
: fork 出一个子进程,子进程执行,不会阻塞 Redis 主线程,默认选项。\n\n这里说 Redis 主线程而不是主进程的主要是因为 Redis 启动之后主要是通过单线程的方式完成主要的工作。如果你想将其描述为 Redis 主进程,也没毛病。
\n
与快照持久化相比,AOF 持久化的实时性更好。默认情况下 Redis 没有开启 AOF(append only file)方式的持久化(Redis 6.0 之后已经默认是开启了),可以通过 appendonly
参数开启:
appendonly yes\n
开启 AOF 持久化后每执行一条会更改 Redis 中的数据的命令,Redis 就会将该命令写入到 AOF 缓冲区 server.aof_buf
中,然后再写入到 AOF 文件中(此时还在系统内核缓存区未同步到磁盘),最后再根据持久化方式( fsync
策略)的配置来决定何时将系统内核缓存区的数据同步到硬盘中的。
只有同步到磁盘中才算持久化保存了,否则依然存在数据丢失的风险,比如说:系统内核缓存区的数据还未同步,磁盘机器就宕机了,那这部分数据就算丢失了。
\nAOF 文件的保存位置和 RDB 文件的位置相同,都是通过 dir
参数设置的,默认的文件名是 appendonly.aof
。
AOF 持久化功能的实现可以简单分为 5 步:
\nwrite
函数(系统调用),write
将数据写入到了系统内核缓冲区之后直接返回了(延迟写)。注意!!!此时并没有同步到磁盘。fsync
策略)向硬盘做同步操作。这一步需要调用 fsync
函数(系统调用), fsync
针对单个文件操作,对其进行强制硬盘同步,fsync
将阻塞直到写入磁盘完成后返回,保证了数据持久化。\n\nLinux 系统直接提供了一些函数用于对文件和设备进行访问和控制,这些函数被称为 系统调用(syscall)。
\n
这里对上面提到的一些 Linux 系统调用再做一遍解释:
\nwrite
:写入系统内核缓冲区之后直接返回(仅仅是写到缓冲区),不会立即同步到硬盘。虽然提高了效率,但也带来了数据丢失的风险。同步硬盘操作通常依赖于系统调度机制,Linux 内核通常为 30s 同步一次,具体值取决于写出的数据量和 I/O 缓冲区的状态。fsync
:fsync
用于强制刷新系统内核缓冲区(同步到到磁盘),确保写磁盘操作结束才会返回。AOF 工作流程图如下:
\n\n在 Redis 的配置文件中存在三种不同的 AOF 持久化方式( fsync
策略),它们分别是:
appendfsync always
:主线程调用 write
执行写操作后,后台线程( aof_fsync
线程)立即会调用 fsync
函数同步 AOF 文件(刷盘),fsync
完成后线程返回,这样会严重降低 Redis 的性能(write
+ fsync
)。appendfsync everysec
:主线程调用 write
执行写操作后立即返回,由后台线程( aof_fsync
线程)每秒钟调用 fsync
函数(系统调用)同步一次 AOF 文件(write
+fsync
,fsync
间隔为 1 秒)appendfsync no
:主线程调用 write
执行写操作后立即返回,让操作系统决定何时进行同步,Linux 下一般为 30 秒一次(write
但不fsync
,fsync
的时机由操作系统决定)。可以看出:这 3 种持久化方式的主要区别在于 fsync
同步 AOF 文件的时机(刷盘)。
为了兼顾数据和写入性能,可以考虑 appendfsync everysec
选项 ,让 Redis 每秒同步一次 AOF 文件,Redis 性能受到的影响较小。而且这样即使出现系统崩溃,用户最多只会丢失一秒之内产生的数据。当硬盘忙于执行写入操作的时候,Redis 还会优雅的放慢自己的速度以便适应硬盘的最大写入速度。
从 Redis 7.0.0 开始,Redis 使用了 Multi Part AOF 机制。顾名思义,Multi Part AOF 就是将原来的单个 AOF 文件拆分成多个 AOF 文件。在 Multi Part AOF 中,AOF 文件被分为三种类型,分别为:
\nMulti Part AOF 不是重点,了解即可,详细介绍可以看看阿里开发者的Redis 7.0 Multi Part AOF 的设计和实现 这篇文章。
\n相关 issue:Redis 的 AOF 方式 #783。
\n关系型数据库(如 MySQL)通常都是执行命令之前记录日志(方便故障恢复),而 Redis AOF 持久化机制是在执行完命令之后再记录日志。
\n\n为什么是在执行完命令之后记录日志呢?
\n这样也带来了风险(我在前面介绍 AOF 持久化的时候也提到过):
\n当 AOF 变得太大时,Redis 能够在后台自动重写 AOF 产生一个新的 AOF 文件,这个新的 AOF 文件和原有的 AOF 文件所保存的数据库状态一样,但体积更小。
\n\n\n\nAOF 重写(rewrite) 是一个有歧义的名字,该功能是通过读取数据库中的键值对来实现的,程序无须对现有 AOF 文件进行任何读入、分析或者写入操作。
\n
由于 AOF 重写会进行大量的写入操作,为了避免对 Redis 正常处理命令请求造成影响,Redis 将 AOF 重写程序放到子进程里执行。
\nAOF 文件重写期间,Redis 还会维护一个 AOF 重写缓冲区,该缓冲区会在子进程创建新 AOF 文件期间,记录服务器执行的所有写命令。当子进程完成创建新 AOF 文件的工作之后,服务器会将重写缓冲区中的所有内容追加到新 AOF 文件的末尾,使得新的 AOF 文件保存的数据库状态与现有的数据库状态一致。最后,服务器用新的 AOF 文件替换旧的 AOF 文件,以此来完成 AOF 文件重写操作。
\n开启 AOF 重写功能,可以调用 BGREWRITEAOF
命令手动执行,也可以设置下面两个配置项,让程序自动决定触发时机:
auto-aof-rewrite-min-size
:如果 AOF 文件大小小于该值,则不会触发 AOF 重写。默认值为 64 MB;auto-aof-rewrite-percentage
:执行 AOF 重写时,当前 AOF 大小(aof_current_size)和上一次重写时 AOF 大小(aof_base_size)的比值。如果当前 AOF 文件大小增加了这个百分比值,将触发 AOF 重写。将此值设置为 0 将禁用自动 AOF 重写。默认值为 100。Redis 7.0 版本之前,如果在重写期间有写入命令,AOF 可能会使用大量内存,重写期间到达的所有写入命令都会写入磁盘两次。
\nRedis 7.0 版本之后,AOF 重写机制得到了优化改进。下面这段内容摘自阿里开发者的从 Redis7.0 发布看 Redis 的过去与未来 这篇文章。
\n\n\nAOF 重写期间的增量数据如何处理一直是个问题,在过去写期间的增量数据需要在内存中保留,写结束后再把这部分增量数据写入新的 AOF 文件中以保证数据完整性。可以看出来 AOF 写会额外消耗内存和磁盘 IO,这也是 Redis AOF 写的痛点,虽然之前也进行过多次改进但是资源消耗的本质问题一直没有解决。
\n阿里云的 Redis 企业版在最初也遇到了这个问题,在内部经过多次迭代开发,实现了 Multi-part AOF 机制来解决,同时也贡献给了社区并随此次 7.0 发布。具体方法是采用 base(全量数据)+inc(增量数据)独立文件存储的方式,彻底解决内存和 IO 资源的浪费,同时也支持对历史 AOF 文件的保存管理,结合 AOF 文件中的时间信息还可以实现 PITR 按时间点恢复(阿里云企业版 Tair 已支持),这进一步增强了 Redis 的数据可靠性,满足用户数据回档等需求。
\n
相关 issue:Redis AOF 重写描述不准确 #1439。
\nAOF 校验机制是 Redis 在启动时对 AOF 文件进行检查,以判断文件是否完整,是否有损坏或者丢失的数据。这个机制的原理其实非常简单,就是通过使用一种叫做 校验和(checksum) 的数字来验证 AOF 文件。这个校验和是通过对整个 AOF 文件内容进行 CRC64 算法计算得出的数字。如果文件内容发生了变化,那么校验和也会随之改变。因此,Redis 在启动时会比较计算出的校验和与文件末尾保存的校验和(计算的时候会把最后一行保存校验和的内容给忽略点),从而判断 AOF 文件是否完整。如果发现文件有问题,Redis 就会拒绝启动并提供相应的错误信息。AOF 校验机制十分简单有效,可以提高 Redis 数据的可靠性。
\n类似地,RDB 文件也有类似的校验机制来保证 RDB 文件的正确性,这里就不重复进行介绍了。
\n由于 RDB 和 AOF 各有优势,于是,Redis 4.0 开始支持 RDB 和 AOF 的混合持久化(默认关闭,可以通过配置项 aof-use-rdb-preamble
开启)。
如果把混合持久化打开,AOF 重写的时候就直接把 RDB 的内容写到 AOF 文件开头。这样做的好处是可以结合 RDB 和 AOF 的优点, 快速加载同时避免丢失过多的数据。当然缺点也是有的, AOF 里面的 RDB 部分是压缩格式不再是 AOF 格式,可读性较差。
\n官方文档地址:https://redis.io/topics/persistence
\n\n关于 RDB 和 AOF 的优缺点,官网上面也给了比较详细的说明Redis persistence,这里结合自己的理解简单总结一下。
\nRDB 比 AOF 优秀的地方:
\nAOF 比 RDB 优秀的地方:
\nFLUSHALL
命令意外地刷新了所有内容后,只要 AOF 文件没有被重写,删除最新命令并重启即可恢复之前的状态。综上:
\n\n\n本文整理完善自:https://mp.weixin.qq.com/s/0Nqfq_eQrUb12QH6eBbHXA ,作者:阿 Q 说代码
\n
这篇文章会详细总结一下可能导致 Redis 阻塞的情况,这些情况也是影响 Redis 性能的关键因素,使用 Redis 的时候应该格外注意!
\nRedis 中的大部分命令都是 O(1)时间复杂度,但也有少部分 O(n) 时间复杂度的命令,例如:
\nKEYS *
:会返回所有符合规则的 key。HGETALL
:会返回一个 Hash 中所有的键值对。LRANGE
:会返回 List 中指定范围内的元素。SMEMBERS
:返回 Set 中的所有元素。SINTER
/SUNION
/SDIFF
:计算多个 Set 的交集/并集/差集。由于这些命令时间复杂度是 O(n),有时候也会全表扫描,随着 n 的增大,执行耗时也会越长,从而导致客户端阻塞。不过, 这些命令并不是一定不能使用,但是需要明确 N 的值。另外,有遍历的需求可以使用 HSCAN
、SSCAN
、ZSCAN
代替。
除了这些 O(n)时间复杂度的命令可能会导致阻塞之外, 还有一些时间复杂度可能在 O(N) 以上的命令,例如:
\nZRANGE
/ZREVRANGE
:返回指定 Sorted Set 中指定排名范围内的所有元素。时间复杂度为 O(log(n)+m),n 为所有元素的数量, m 为返回的元素数量,当 m 和 n 相当大时,O(n) 的时间复杂度更小。ZREMRANGEBYRANK
/ZREMRANGEBYSCORE
:移除 Sorted Set 中指定排名范围/指定 score 范围内的所有元素。时间复杂度为 O(log(n)+m),n 为所有元素的数量, m 被删除元素的数量,当 m 和 n 相当大时,O(n) 的时间复杂度更小。Redis 提供了两个命令来生成 RDB 快照文件:
\nsave
: 同步保存操作,会阻塞 Redis 主线程;bgsave
: fork 出一个子进程,子进程执行,不会阻塞 Redis 主线程,默认选项。默认情况下,Redis 默认配置会使用 bgsave
命令。如果手动使用 save
命令生成 RDB 快照文件的话,就会阻塞主线程。
Redis AOF 持久化机制是在执行完命令之后再记录日志,这和关系型数据库(如 MySQL)通常都是执行命令之前记录日志(方便故障恢复)不同。
\n\n为什么是在执行完命令之后记录日志呢?
\n这样也带来了风险(我在前面介绍 AOF 持久化的时候也提到过):
\n开启 AOF 持久化后每执行一条会更改 Redis 中的数据的命令,Redis 就会将该命令写入到 AOF 缓冲区 server.aof_buf
中,然后再根据 appendfsync
配置来决定何时将其同步到硬盘中的 AOF 文件。
在 Redis 的配置文件中存在三种不同的 AOF 持久化方式( fsync
策略),它们分别是:
appendfsync always
:主线程调用 write
执行写操作后,后台线程( aof_fsync
线程)立即会调用 fsync
函数同步 AOF 文件(刷盘),fsync
完成后线程返回,这样会严重降低 Redis 的性能(write
+ fsync
)。appendfsync everysec
:主线程调用 write
执行写操作后立即返回,由后台线程( aof_fsync
线程)每秒钟调用 fsync
函数(系统调用)同步一次 AOF 文件(write
+fsync
,fsync
间隔为 1 秒)appendfsync no
:主线程调用 write
执行写操作后立即返回,让操作系统决定何时进行同步,Linux 下一般为 30 秒一次(write
但不fsync
,fsync
的时机由操作系统决定)。当后台线程( aof_fsync
线程)调用 fsync
函数同步 AOF 文件时,需要等待,直到写入完成。当磁盘压力太大的时候,会导致 fsync
操作发生阻塞,主线程调用 write
函数时也会被阻塞。fsync
完成后,主线程执行 write
才能成功返回。
关于 AOF 工作流程的详细介绍可以查看:Redis 持久化机制详解,有助于理解 AOF 刷盘阻塞。
\nBGREWRITEAOF
命令时,Redis 服务器会维护一个 AOF 重写缓冲区,该缓冲区会在子线程创建新 AOF 文件期间,记录服务器执行的所有写命令。阻塞就是出现在第 2 步的过程中,将缓冲区中新数据写到新文件的过程中会产生阻塞。
\n相关阅读:Redis AOF 重写阻塞问题分析。
\n如果一个 key 对应的 value 所占用的内存比较大,那这个 key 就可以看作是 bigkey。具体多大才算大呢?有一个不是特别精确的参考标准:
\n大 key 造成的阻塞问题如下:
\n当我们在使用 Redis 自带的 --bigkeys
参数查找大 key 时,最好选择在从节点上执行该命令,因为主节点上执行时,会阻塞主节点。
我们还可以使用 SCAN 命令来查找大 key;
\n通过分析 RDB 文件来找出 big key,这种方案的前提是 Redis 采用的是 RDB 持久化。网上有现成的工具:
\n删除操作的本质是要释放键值对占用的内存空间。
\n释放内存只是第一步,为了更加高效地管理内存空间,在应用程序释放内存时,操作系统需要把释放掉的内存块插入一个空闲内存块的链表,以便后续进行管理和再分配。这个过程本身需要一定时间,而且会阻塞当前释放内存的应用程序。
\n所以,如果一下子释放了大量内存,空闲内存块链表操作时间就会增加,相应地就会造成 Redis 主线程的阻塞,如果主线程发生了阻塞,其他所有请求可能都会超时,超时越来越多,会造成 Redis 连接耗尽,产生各种异常。
\n删除大 key 时建议采用分批次删除和异步删除的方式进行。
\n清空数据库和上面 bigkey 删除也是同样道理,flushdb
、flushall
也涉及到删除和释放所有的键值对,也是 Redis 的阻塞点。
Redis 集群可以进行节点的动态扩容缩容,这一过程目前还处于半自动状态,需要人工介入。
\n在扩缩容的时候,需要进行数据迁移。而 Redis 为了保证迁移的一致性,迁移所有操作都是同步操作。
\n执行迁移时,两端的 Redis 均会进入时长不等的阻塞状态,对于小 Key,该时间可以忽略不计,但如果一旦 Key 的内存使用过大,严重的时候会触发集群内的故障转移,造成不必要的切换。
\n什么是 Swap? Swap 直译过来是交换的意思,Linux 中的 Swap 常被称为内存交换或者交换分区。类似于 Windows 中的虚拟内存,就是当内存不足的时候,把一部分硬盘空间虚拟成内存使用,从而解决内存容量不足的情况。因此,Swap 分区的作用就是牺牲硬盘,增加内存,解决 VPS 内存不够用或者爆满的问题。
\nSwap 对于 Redis 来说是非常致命的,Redis 保证高性能的一个重要前提是所有的数据在内存中。如果操作系统把 Redis 使用的部分内存换出硬盘,由于内存与硬盘的读写速度差几个数量级,会导致发生交换后的 Redis 性能急剧下降。
\n识别 Redis 发生 Swap 的检查方法如下:
\n1、查询 Redis 进程号
\nreids-cli -p 6383 info server | grep process_id\nprocess_id: 4476\n
2、根据进程号查询内存交换信息
\ncat /proc/4476/smaps | grep Swap\nSwap: 0kB\nSwap: 0kB\nSwap: 4kB\nSwap: 0kB\nSwap: 0kB\n.....\n
如果交换量都是 0KB 或者个别的是 4KB,则正常。
\n预防内存交换的方法:
\necho 10 > /proc/sys/vm/swappiness
Redis 是典型的 CPU 密集型应用,不建议和其他多核 CPU 密集型服务部署在一起。当其他进程过度消耗 CPU 时,将严重影响 Redis 的吞吐量。
\n可以通过reids-cli --stat
获取当前 Redis 使用情况。通过top
命令获取进程对 CPU 的利用率等信息 通过info commandstats
统计信息分析出命令不合理开销时间,查看是否是因为高算法复杂度或者过度的内存优化问题。
连接拒绝、网络延迟,网卡软中断等网络问题也可能会导致 Redis 阻塞。
\n缓存是一个有效且实用的系统性能优化的手段,不论是操作系统还是各种软件和网站或多或少都用到了缓存。
\n然而,有经验的 DBA 都建议生产环境中把 MySQL 自带的 Query Cache(查询缓存)给关掉。而且,从 MySQL 5.7.20 开始,就已经默认弃用查询缓存了。在 MySQL 8.0 及之后,更是直接删除了查询缓存的功能。
\n这又是为什么呢?查询缓存真就这么鸡肋么?
\n带着如下几个问题,我们正式进入本文。
\nMySQL 体系架构如下图所示:
\n\n为了提高完全相同的查询语句的响应速度,MySQL Server 会对查询语句进行 Hash 计算得到一个 Hash 值。MySQL Server 不会对 SQL 做任何处理,SQL 必须完全一致 Hash 值才会一样。得到 Hash 值之后,通过该 Hash 值到查询缓存中匹配该查询的结果。
\n也就是说,一个查询语句(select)到了 MySQL Server 之后,会先到查询缓存看看,如果曾经执行过的话,就直接返回结果集给客户端。
\n\n通过 show variables like '%query_cache%'
命令可以查看查询缓存相关的信息。
8.0 版本之前的话,打印的信息可能是下面这样的:
\nmysql> show variables like '%query_cache%';\n+
\n\n推荐语:波波老师的一篇文章,写的非常好,不光是对技术成长有帮助,其他领域也是同样适用的!建议反复阅读,形成一套自己的技术成长策略。
\n\n
在波波的微信技术交流群里头,经常有学员问关于技术人该如何学习成长的问题,虽然是微信交流,但我依然可以感受到小伙伴们焦虑的心情。
\n技术人为啥焦虑? 恕我直言,说白了是胆识不足格局太小。胆就是胆量,焦虑的人一般对未来的不确定性怀有恐惧。识就是见识,焦虑的人一般看不清楚周围世界,也看不清自己和适合自己的道路。格局也称志向,容易焦虑的人通常视野窄志向小。如果从战略和管理的视角来看,就是对自己和周围世界的认知不足,没有一个清晰和长期的学习成长战略,也没有可执行的阶段性目标计划+严格的执行。
\n因为问此类问题的学员很多,让我感觉有点烦了,为了避免重复回答,所以我专门总结梳理了这篇长文,试图统一来回答这类问题。如果后面还有学员问类似问题,我会引导他们来读这篇文章,然后让他们用三个月、一年甚至更长的时间,去思考和回答这样一个问题:你的技术成长战略究竟是什么? 如果你想清楚了这个问题,有清晰和可落地的答案,那么恭喜你,你只需按部就班执行就好,根本无需焦虑,你实现自己的战略目标并做出成就只是一个时间问题;否则,你仍然需要通过不断磨炼+思考,务必去搞清楚这个人生的大问题!!!
\n下面我们来看一些行业技术大牛是怎么做的。
\n我们知道软件设计是有设计模式(Design Pattern)的,其实技术人的成长也是有成长模式(Growth Pattern)的。波波经常在 Linkedin 上看一些技术大牛的成长履历,探究其中的成长模式,从而启发制定自己的技术成长战略。
\n当然,很少有技术大牛会清晰地告诉你他们的技术成长战略,以及每一年的细分落地计划。但是,这并不妨碍我们通过他们的过往履历和产出成果,去溯源他们的技术成长战略。实际上, 越是牛逼的技术人,他们的技术成长战略和路径越是清晰,我们越容易从中探究出一些成功的模式。
\n国内的开发者大都热衷于系统性能优化,有些人甚至三句话离不开高性能/高并发,但真正能深入这个领域,做到专家级水平的却寥寥无几。
\n我这边要特别介绍的这个技术大牛叫 Brendan Gregg ,他是系统性能领域经典书《System Performance: Enterprise and the Cloud》(中文版《性能之巅:洞悉系统、企业和云计算》)的作者,也是著名的性能分析利器火焰图(Flame Graph)的作者。
\nBrendan Gregg 之前是 Netflix 公司的高级性能架构师,在 Netflix 工作近 7 年。2022 年 4 月,他离开了 Netflix 去了 Intel,担任院士职位。
\n\n总体上,他已经在系统性能领域深耕超过 10 年,Brendan Gregg 的过往履历可以在 linkedin 上看到。在这 10 年间,除了书籍以外,Brendan Gregg 还产出了超过上百份和系统性能相关的技术文档,演讲视频/ppt,还有各种工具软件,相关内容都整整齐齐地分享在他的技术博客上,可以说他是一个非常高产的技术大牛。
\n\n上图来自 Brendan Gregg 的新书《BPF Performance Tools: Linux System and Application Observability》。从这个图可以看出,Brendan Gregg 对系统性能领域的掌握程度,已经深挖到了硬件、操作系统和应用的每一个角落,可以说是 360 度无死角,整个计算机系统对他来说几乎都是透明的。波波认为,Brendan Gregg 是名副其实的,世界级的,系统性能领域的大神级人物。
\n我要分享的第二个技术大牛是 Jay Kreps,他是知名的开源消息中间件 Kafka 的创始人/架构师,也是 Confluent 公司的联合创始人和 CEO,Confluent 公司是围绕 Kafka 开发企业级产品和服务的技术公司。
\n从Jay Kreps 的 Linkedin 的履历上我们可以看出,Jay Kreps 之前在 Linkedin 工作了 7 年多(2007.6 ~ 2014. 9),从高级工程师、工程主管,一直做到首席资深工程师。Kafka 大致是在 2010 年,Jay Kreps 在 Linkedin 发起的一个项目,解决 Linkedin 内部的大数据采集、存储和消费问题。之后,他和他的团队一直专注 Kafka 的打磨,开源(2011 年初)和社区生态的建设。
\n到 2014 年底,Kafka 在社区已经非常成功,有了一个比较大的用户群,于是 Jay Kreps 就和几个早期作者一起离开了 Linkedin,成立了Confluent 公司,开始了 Kafka 和周边产品的企业化服务道路。今年(2020.4 月),Confluent 公司已经获得 E 轮 2.5 亿美金融资,公司估值达到 45 亿美金。从 Kafka 诞生到现在,Jay Kreps 差不多在这个产品和公司上投入了整整 10 年。
\n\n上图是 Confluent 创始人三人组,一个非常有意思的组合,一个中国人(左),一个印度人(右),中间的 Jay Kreps 是美国人。
\n我之所以对 Kafka 和 Jay Kreps 的印象特别深刻,是因为在 2012 年下半年,我在携程框架部也是专门搞大数据采集的,我还开发过一套功能类似 Kafka 的 Log Collector + Agent 产品。我记得同时期有不止 4 个同类型的开源产品:Facebook Scribe、Apache Chukwa、Apache Flume 和 Apache Kafka。现在回头看,只有 Kafka 走到现在发展得最好,这个和创始人的专注和持续投入是分不开的,当然背后和几个创始人的技术大格局也是分不开的。
\n当年我对战略性思维几乎没有概念,还处在什么技术都想学、认为各种项目做得越多越牛的阶段。搞了半年的数据采集以后,我就掉头搞其它“更有趣的”项目去了(从这个事情的侧面,也可以看出我当年的技术格局是很小的)。中间我陆续关注过 Jay 的一些创业动向,但是没想到他能把 Confluent 公司发展到目前这个规模。现在回想,其实在十年前,Jay Kreps 对自己的技术成长就有比较明确的战略性思考,也具有大的技术格局和成事的一些必要特质。Jay Kreps 和 Kafka 给我上了一堂生动的技术战略和实践课。
\n介绍到这里,有些同学可能会反驳说:波波你讲的这些大牛都是学历背景好,功底扎实起点高,所以他们才更能成功。其实不然,这里我再要介绍一位技术媒体界的大 V 叫 Brad Traversy,大家可以看他的 Linkedin 简历,背景很一般,学历差不多是一个非正规的社区大学(相当于大专),没有正规大厂工作经历,有限几份工作一直是在做网站外包。
\n\n但是!!!Brad Traversy 目前是技术媒体领域的一个大 V,当前他在 Youtube 上的频道有 138 万多的订阅量,10 年累计输出 Web 开发和编程相关教学视频超过 800 个。Brad Traversy 也是 Udemy 上的一个成功讲师,目前已经在 Udemy 上累计输出课程 19 门,购课学生数量近 42 万。
\n\nBrad Traversy 目前是自由职业者,他的 Youtube 广告+Udemy 课程的收入相当不错。
\n就是这样一位技术媒体大 V,你很难想象,在年轻的时候,贴在他身上的标签是:不良少年,酗酒,抽烟,吸毒,纹身,进监狱。。。直
\n到结婚后的第一个孩子诞生,他才开始担起责任做出改变,然后凭借对技术的一腔热情,开始在 Youtube 平台上持续输出免费课程。从此他找到了适合自己的战略目标,然后人生开始发生各种积极的变化。。。如果大家对 Brad Traversy 的过往经历感兴趣,推荐观看他在 Youtube 上的自述视频《My Struggles & Success》。
\n\n我粗略浏览了Brad Traversy 在 Youtube 上的所有视频,10 年总计输出 800+视频,平均每年 80+。第一个视频提交于 2010 年 8 月,刚开始几年几乎没有订阅量,2017 年 1 月订阅量才到 50k,这中间差不多隔了 6 年。2017.10 月订阅量猛增到 200k,2018 年 3 月订阅量到 300k。当前 2021.1 月,订阅量达到 138 万。可以认为从 2017 开始,也就是在积累了 6 ~ 7 年后,他的订阅量开始出现拐点。如果把这些数据画出来,将会是一条非常漂亮的复利曲线。
\nBrendan Gregg,Jay Kreps 和 Brad Traversy 三个人走的技术路线各不相同,但是他们的成功具有共性或者说模式:
\n1、找到了适合自己的长期战略目标。
\n2、专注深耕一个(或有限几个相关的)细分领域(Niche),保持定力,不随便切换领域。
\n3、长期投入,三人都持续投入了 10 年。
\n4、年度细分计划+持续可量化的价值产出(Persistent & Measurable Value Output)。
\n5、以终为始是牛人和普通人的一大区别。
\n普通人通常走一步算一步,很少长远规划。牛人通 常是先有远大目标,然后采用倒推法,将大目标细化到每年/月/周的详细落地计划。Brendan Gregg,Jay Kreps 和 Brad Traversy 三人都是以终为始的典型。
\n\n上面总结了几位技术大牛的成长模式,其中一个重点就是:这些大牛的成长都是通过 持续有价值产出(Persistent Valuable Output) 来驱动的。持续产出为啥如此重要,这个还要从下面的学习金字塔说起。
\n学习金字塔是美国缅因州国家训练实验室的研究成果,它认为:
\n\n\n\n
\n- 我们平时上课听讲之后,学习内容平均留存率大致只有 5%左右;
\n- 书本阅读的平均留存率大致只有 10%左右;
\n- 学习配上视听效果的课程,平均留存率大致在 20%左右;
\n- 老师实际动手做实验演示后的平均留存率大致在 30%左右;
\n- 小组讨论(尤其是辩论后)的平均留存率可以达到 50%左右;
\n- 在实践中实际应用所学之后,平均留存率可以达到 75%左右;
\n- 在实践的基础上,再把所学梳理出来,转而再传授给他人后,平均留存率可以达到 90%左右。
\n
上面列出的 7 种学习方法,前四种称为 被动学习 ,后三种称为 主动学习。
\n拿学游泳做个类比,被动学习相当于你看别人游泳,而主动学习则是你自己要下水去游。我们知道游泳或者跑步之类的运动是要燃烧身体卡路里的,这样才能达到锻炼身体和长肌肉的效果(肌肉是卡路里燃烧的结果)。如果你只是看别人游泳,自己不实际去游,是不会长肌肉的。同样的,主动学习也是要燃烧脑部卡路里的,这样才能达到训练大脑和长脑部“肌肉”的效果。
\n我们也知道,燃烧身体的卡路里,通常会让人感觉不舒适,如果燃烧身体卡路里会让人感觉舒适的话,估计这个世界上应该不会有胖子这类人。同样,燃烧脑部卡路里也会让人感觉不适、紧张、出汗或语无伦次,如果燃烧脑部卡路里会让人感觉舒适的话,估计这个世界上人人都很聪明,人人都能发挥最大潜能。当然,这些不舒适是短期的,长期会使你更健康和聪明。波波一直认为, 人与人之间的先天身体其实都差不多,但是后天身体素质和能力有差异,这些差异,很大程度是由后天对身体和大脑的训练质量、频度和强度所造成的。
\n明白这个道理之后,心智成熟和自律的人就会对自己进行持续地 刻意训练 。这个刻意训练包括对身体的训练,比如波波现在每天坚持跑步 3km,走 3km,每天做 60 个仰卧起坐,5 分钟平板撑等等,每天保持让身体燃烧一定量的卡路里。刻意训练也包括对大脑的训练,比如波波现在每天做项目写代码 coding(训练脑+手),平均每天在 B 站上输出十分钟免费视频(训练脑+口头表达),另外有定期总结输出公众号文章(训练脑+文字表达),还有每天打半小时左右的平衡球(下图)或古墓丽影游戏(训练小脑+手),每天保持让大脑燃烧一定量的卡路里,并保持一定强度(适度不适感)。
\n\n关于刻意训练的专业原理和方法论,推荐看书籍《刻意练习》。
\n\n注意,如果你平时从来不做举重锻炼的,那么某天突然做举重会很不适应甚至受伤。脑部训练也是一样的,如果你从来没有做过视频输出,那么刚开始做会很不适应,做出来的视频质量会很差。不过没有关系,任何训练都是一个循序渐进,不断强化的过程。等大脑相关区域的\"肌肉\"长出来以后,会逐步进入正循环,后面会越来越顺畅,相关\"肌肉\"会越来越发达。所以,和健身一样,健脑也不能遇到困难就放弃,需要循序渐进(Incremental)+持续地(Persistent)刻意训练。
\n理解了学习金字塔和刻意训练以后,现在再来看 Brendan Gregg,Jay Kreps 和 Brad Traversy 这些大牛的做法,他们的学习成长都是建立在持续有价值产出的基础上的,这些产出都是刻意训练+燃烧脑部卡路里的成果。他们的产出要么是建立在实践基础上的产出,例如 Jay Kreps 的 Kafka 开源项目和 Confluent 公司;要么是在实践的基础上,再整理传授给其他人的产出,例如,Brendan Greeg 的技术演讲 ppt/视频,书籍,还有 Brad Traversy 的教学视频等等。换句话说,他们一直在学习金字塔的 5 ~ 7 层主动和高效地学习。并且,他们的学习产出还可以获得用户使用,有客户价值(Customer Value),有用户就有反馈和度量。记住,有反馈和度量的学习,也称闭环学习,它是能够不断改进提升的;反之,没有反馈和度量的学习,无法改进提升。
\n现在,你也应该明白,晒个书单秀个技能图谱很简单,读个书上个课也不难。但是要你给出 5 ~ 10 年的总体技术成长战略,再基于这个战略给出每年的细分落地计划(尤其是产出计划),然后再严格按计划执行,这的确是很难的事情。这需要大量的实践训练+深度思考,要燃烧大量的脑部卡路里!但这是上天设置的进化法则,成长为真正的技术大牛如同成长为一流的运动员,是需要通过燃烧与之相匹配量的卡路里来交换的。成长为真正的技术大牛,也是需要通过产出与之匹配的社会价值来交换的,只有这样社会才能正常进化。你推进了社会进化,社会才会回馈你。如果不是这样,社会就无法正常进化。
\n一般毕业生刚进入企业工作的时候,思考大都是以天/星期/月为单位的,基本上都是今天学个什么技术,明天学个什么语言,很少会去思考一年甚至更长的目标。这是个眼前漆黑看不到的懵懂时期,捕捉到机会点的能力和概率都非常小。
\n工作了三年以后,悟性好的人通常会以一年为思考周期,制定和实施一些年度计划。这个时期是相信天赋和比拼能力的阶段,可以捕捉到一些小机会。
\n工作了五年以后,一些悟性好的人会产生出一定的胆识和眼光,他们会以 3 ~ 5 年为周期来制定和实施计划,开始主动布局去捕捉一些中型机会点。
\n工作了十年以后,悟性高的人会看到模式和规则变化,例如看出行业发展模式,还有人才的成长模式等,于是开始诞生出战略性思维。然后他们会以 5 ~ 10 年为周期来制定和实施自己的战略计划,开始主动布局去捕捉一些中大机会点。Brendan Gregg,Jay Kreps 和 Brad Traversy 都是属于这个阶段的人。
\n当然还有很少一些更牛的时代精英,他们能够看透时代和人性,他们的思考是以一生甚至更长时间为单位的,这些超人不在本文讨论范围内。
\n1、以 5 ~ 10 年为周期去布局谋划你的战略。
\n现在大学生毕业的年龄一般在 22 ~ 23 岁,那么在工作了十年后,也就是在你 32 ~ 33 岁的时候,你也差不多看了十年了,应该对自己和周围的世界(你的行业和领域)有一个比较深刻的领悟了。如果你到这个年纪还懵懵懂懂,今天抓东明天抓西,那么只能说你的胆识格局是相当的低。在当前 IT 行业竞争这么激烈的情况下,到 35 岁被下岗可能就在眼前了。
\n有了战略性思考,你应该以 5 ~ 10 年为周期去布局谋划你的战略。以 Brendan Gregg,Jay Kreps 和 Brad Traversy 这些大牛为例,人生若真的要干点成就出来,投入周期一般都要十年的。从 33 岁开始,你大致有 3 个十年,因为到 60 岁以后,一般人都老眼昏花干不了大事了。如果你悟性差一点,到 40 岁才开始规划,那么你大致还有 2 个十年。如果你规划好了,这 2 ~ 3 个十年可以成就不小的事业。否则,你很可能一生都成就不了什么事业,或者一直在帮助别人成就别人的事业。
\n2、专注自己的精力。
\n考虑到人生能干事业的时间也就是 2 ~ 3 个十年,你会发现人生其实很短暂,这时候你会把精力都投入到实现你的十年战略上去,没有时间再浪费在比如网上的闲聊和扯皮争论上去。
\n3、细分落地计划尤其是产出计划。
\n有了十年战略方向,下一步是每年的细分落地计划,尤其是产出计划。这些计划主要应该工作在学习金字塔的 5/6/7 层。产出应该是刻意训练+燃烧卡路里的结果,每天让身体和大脑都保持燃烧一定量的卡路里。
\n4、产出有价值的东西形成正反馈。
\n产出应该有客户价值,自己能学习(自己成长进化),对别人还有用(推动社会成长进化),这样可以得到用户回馈和度量,形成一个闭环,可以持续改进和提升你的学习。
\n5、少即是多。
\n深耕一个(或有限几个相关的)领域。所有细分计划应该紧密围绕你的战略展开。克制内心欲望,不要贪多和分心,不要被喧嚣的世界所迷惑。
\n6、战略方向+细分计划都要写下来,定期 review 优化。
\n7、要有定力,持续努力。
\n曲则全、枉则直,战略实现是不可能直线的。战略方向和细分计划通常要按需调整,尤其在早期,但是最终要收敛。如果老是变不收敛,就是缺乏战略定力,是个必须思考和解决的大问题。
\n别人的成长战略可以参考,但是不要刻意去模仿,你有你自己的颜色,你应该成为独一无二的你。
\n战略方向和细分计划明确了,接下来就是按部就班执行,十年如一日铁打不动。
\n8、慢就是快。
\n战略目标的实现也和种树一样是生长出来的,需要时间耐心栽培,记住**慢就是快。**焦虑纠结的时候,像念经一样默念王阳明《传习录》中的教诲:
\n\n\n\n", "image": "https://oss.javaguide.cn/github/javaguide/high-quality-technical-articles/cdb11ce2f1c3a69fd19e922a7f5f59bf.png", "date_published": "2023-02-23T04:45:00.000Z", "date_modified": "2023-12-30T09:14:13.000Z", "authors": [ { "name": "波波微课" } ], "tags": [ "技术文章精选集" ] }, { "title": "网络攻击常见手段总结", "url": "https://javaguide.cn/cs-basics/network/network-attack-means.html", "id": "https://javaguide.cn/cs-basics/network/network-attack-means.html", "summary": " 本文整理完善自TCP/IP 常见攻击手段 - 暖蓝笔记 - 2021这篇文章。 这篇文章的内容主要是介绍 TCP/IP 常见攻击手段,尤其是 DDoS 攻击,也会补充一些其他的常见网络攻击手段。 IP 欺骗 IP 是什么? 在网络中,所有的设备都会分配一个地址。这个地址就仿佛小蓝的家地址「多少号多少室」,这个号就是分配给整个子网的,「室」对应的号码即...", "content_html": "立志用功,如种树然。方其根芽,犹未有干;及其有干,尚未有枝;枝而后叶,叶而后花实。初种根时,只管栽培灌溉。勿作枝想,勿作花想,勿作实想。悬想何益?但不忘栽培之功,怕没有枝叶花实?
\n译文:
\n实现战略目标,就像种树一样。刚开始只是一个小根芽,树干还没有长出来;树干长出来了,枝叶才能慢慢长出来;树枝长出来,然后才能开花和结果。刚开始种树的时候,只管栽培灌溉,别老是纠结枝什么时候长出来,花什么时候开,果实什么时候结出来。纠结有什么好处呢?只要你坚持投入栽培,还怕没有枝叶花实吗?
\n
\n\n本文整理完善自TCP/IP 常见攻击手段 - 暖蓝笔记 - 2021这篇文章。
\n
这篇文章的内容主要是介绍 TCP/IP 常见攻击手段,尤其是 DDoS 攻击,也会补充一些其他的常见网络攻击手段。
\n在网络中,所有的设备都会分配一个地址。这个地址就仿佛小蓝的家地址「多少号多少室」,这个号就是分配给整个子网的,「室」对应的号码即分配给子网中计算机的,这就是网络中的地址。「号」对应的号码为网络号,「室」对应的号码为主机号,这个地址的整体就是 IP 地址。
\n通过 IP 地址,我们就可以知道判断访问对象服务器的位置,从而将消息发送到服务器。一般发送者发出的消息首先经过子网的集线器,转发到最近的路由器,然后根据路由位置访问下一个路由器的位置,直到终点
\nIP 头部格式 :
\n\n骗呗,拐骗,诱骗!
\nIP 欺骗技术就是伪造某台主机的 IP 地址的技术。通过 IP 地址的伪装使得某台主机能够伪装另外的一台主机,而这台主机往往具有某种特权或者被另外的主机所信任。
\n假设现在有一个合法用户 (1.1.1.1) 已经同服务器建立正常的连接,攻击者构造攻击的 TCP 数据,伪装自己的 IP 为 1.1.1.1,并向服务器发送一个带有 RST 位的 TCP 数据段。服务器接收到这样的数据后,认为从 1.1.1.1 发送的连接有错误,就会清空缓冲区中建立好的连接。
\n这时,如果合法用户 1.1.1.1 再发送合法数据,服务器就已经没有这样的连接了,该用户就必须从新开始建立连接。攻击时,伪造大量的 IP 地址,向目标发送 RST 数据,使服务器不对合法用户服务。虽然 IP 地址欺骗攻击有着相当难度,但我们应该清醒地意识到,这种攻击非常广泛,入侵往往从这种攻击开始。
\n\n虽然无法预防 IP 欺骗,但可以采取措施来阻止伪造数据包渗透网络。入口过滤 是防范欺骗的一种极为常见的防御措施,如 BCP38(通用最佳实践文档)所示。入口过滤是一种数据包过滤形式,通常在网络边缘设备上实施,用于检查传入的 IP 数据包并确定其源标头。如果这些数据包的源标头与其来源不匹配或者看上去很可疑,则拒绝这些数据包。一些网络还实施出口过滤,检查退出网络的 IP 数据包,确保这些数据包具有合法源标头,以防止网络内部用户使用 IP 欺骗技术发起出站恶意攻击。
\nSYN Flood 是互联网上最原始、最经典的 DDoS(Distributed Denial of Service,分布式拒绝服务)攻击之一,旨在耗尽可用服务器资源,致使服务器无法传输合法流量
\nSYN Flood 利用了 TCP 协议的三次握手机制,攻击者通常利用工具或者控制僵尸主机向服务器发送海量的变源 IP 地址或变源端口的 TCP SYN 报文,服务器响应了这些报文后就会生成大量的半连接,当系统资源被耗尽后,服务器将无法提供正常的服务。
\n增加服务器性能,提供更多的连接能力对于 SYN Flood 的海量报文来说杯水车薪,防御 SYN Flood 的关键在于判断哪些连接请求来自于真实源,屏蔽非真实源的请求以保障正常的业务请求能得到服务。
TCP SYN Flood 攻击利用的是 TCP 的三次握手(SYN -> SYN/ACK -> ACK),假设连接发起方是 A,连接接受方是 B,即 B 在某个端口(Port)上监听 A 发出的连接请求,过程如下图所示,左边是 A,右边是 B。
\n\nA 首先发送 SYN(Synchronization)消息给 B,要求 B 做好接收数据的准备;B 收到后反馈 SYN-ACK(Synchronization-Acknowledgement) 消息给 A,这个消息的目的有两个:
\n大家注意到没有,最关键的一点在于双方是否都按对方的要求进入了可以接收消息的状态。而这个状态的确认主要是双方将要使用的消息序号(SequenceNum),TCP 为保证消息按发送顺序抵达接收方的上层应用,需要用消息序号来标记消息的发送先后顺序的。
\nTCP是「双工」(Duplex)连接,同时支持双向通信,也就是双方同时可向对方发送消息,其中 SYN 和 SYN-ACK 消息开启了 A→B 的单向通信通道(B 获知了 A 的消息序号);SYN-ACK 和 ACK 消息开启了 B→A 单向通信通道(A 获知了 B 的消息序号)。
\n上面讨论的是双方在诚实守信,正常情况下的通信。
\n但实际情况是,网络可能不稳定会丢包,使握手消息不能抵达对方,也可能是对方故意不按规矩来,故意延迟或不发送握手确认消息。
\n假设 B 通过某 TCP 端口提供服务,B 在收到 A 的 SYN 消息时,积极的反馈了 SYN-ACK 消息,使连接进入半开状态,因为 B 不确定自己发给 A 的 SYN-ACK 消息或 A 反馈的 ACK 消息是否会丢在半路,所以会给每个待完成的半开连接都设一个Timer,如果超过时间还没有收到 A 的 ACK 消息,则重新发送一次 SYN-ACK 消息给 A,直到重试超过一定次数时才会放弃。
\n\nB 为帮助 A 能顺利连接,需要分配内核资源维护半开连接,那么当 B 面临海量的连接 A 时,如上图所示,SYN Flood 攻击就形成了。攻击方 A 可以控制肉鸡向 B 发送大量 SYN 消息但不响应 ACK 消息,或者干脆伪造 SYN 消息中的 Source IP,使 B 反馈的 SYN-ACK 消息石沉大海,导致 B 被大量注定不能完成的半开连接占据,直到资源耗尽,停止响应正常的连接请求。
\n恶意用户可通过三种不同方式发起 SYN Flood 攻击:
\n目标设备安装的每个操作系统都允许具有一定数量的半开连接。若要响应大量 SYN 数据包,一种方法是增加操作系统允许的最大半开连接数目。为成功扩展最大积压工作,系统必须额外预留内存资源以处理各类新请求。如果系统没有足够的内存,无法应对增加的积压工作队列规模,将对系统性能产生负面影响,但仍然好过拒绝服务。
\n另一种缓解策略是在填充积压工作后覆盖最先创建的半开连接。这项策略要求完全建立合法连接的时间低于恶意 SYN 数据包填充积压工作的时间。当攻击量增加或积压工作规模小于实际需求时,这项特定的防御措施将不奏效。
\n此策略要求服务器创建 Cookie。为避免在填充积压工作时断开连接,服务器使用 SYN-ACK 数据包响应每一项连接请求,而后从积压工作中删除 SYN 请求,同时从内存中删除请求,保证端口保持打开状态并做好重新建立连接的准备。如果连接是合法请求并且已将最后一个 ACK 数据包从客户端机器发回服务器,服务器将重建(存在一些限制)SYN 积压工作队列条目。虽然这项缓解措施势必会丢失一些 TCP 连接信息,但好过因此导致对合法用户发起拒绝服务攻击。
\nUDP Flood 也是一种拒绝服务攻击,将大量的用户数据报协议(UDP)数据包发送到目标服务器,目的是压倒该设备的处理和响应能力。防火墙保护目标服务器也可能因 UDP 泛滥而耗尽,从而导致对合法流量的拒绝服务。
\nUDP Flood 主要通过利用服务器响应发送到其中一个端口的 UDP 数据包所采取的步骤。在正常情况下,当服务器在特定端口接收到 UDP 数据包时,会经过两个步骤:
\n举个例子。假设今天要联系酒店的小蓝,酒店客服接到电话后先查看房间的列表来确保小蓝在客房内,随后转接给小蓝。
\n首先,接待员接收到呼叫者要求连接到特定房间的电话。接待员然后需要查看所有房间的清单,以确保客人在房间中可用,并愿意接听电话。碰巧的是,此时如果突然间所有的电话线同时亮起来,那么他们就会很快就变得不堪重负了。
\n当服务器接收到每个新的 UDP 数据包时,它将通过步骤来处理请求,并利用该过程中的服务器资源。发送 UDP 报文时,每个报文将包含源设备的 IP 地址。在这种类型的 DDoS 攻击期间,攻击者通常不会使用自己的真实 IP 地址,而是会欺骗 UDP 数据包的源 IP 地址,从而阻止攻击者的真实位置被暴露并潜在地饱和来自目标的响应数据包服务器。
\n由于目标服务器利用资源检查并响应每个接收到的 UDP 数据包的结果,当接收到大量 UDP 数据包时,目标的资源可能会迅速耗尽,导致对正常流量的拒绝服务。
\n\n大多数操作系统部分限制了 ICMP 报文的响应速率,以中断需要 ICMP 响应的 DDoS 攻击。这种缓解的一个缺点是在攻击过程中,合法的数据包也可能被过滤。如果 UDP Flood 的容量足够高以使目标服务器的防火墙的状态表饱和,则在服务器级别发生的任何缓解都将不足以应对目标设备上游的瓶颈。
\nHTTP Flood 是一种大规模的 DDoS(Distributed Denial of Service,分布式拒绝服务)攻击,旨在利用 HTTP 请求使目标服务器不堪重负。目标因请求而达到饱和,且无法响应正常流量后,将出现拒绝服务,拒绝来自实际用户的其他请求。
\n\nHTTP 洪水攻击是“第 7 层”DDoS 攻击的一种。第 7 层是 OSI 模型的应用程序层,指的是 HTTP 等互联网协议。HTTP 是基于浏览器的互联网请求的基础,通常用于加载网页或通过互联网发送表单内容。缓解应用程序层攻击特别复杂,因为恶意流量和正常流量很难区分。
\n为了获得最大效率,恶意行为者通常会利用或创建僵尸网络,以最大程度地扩大攻击的影响。通过利用感染了恶意软件的多台设备,攻击者可以发起大量攻击流量来进行攻击。
\nHTTP 洪水攻击有两种:
\n如前所述,缓解第 7 层攻击非常复杂,而且通常要从多方面进行。一种方法是对发出请求的设备实施质询,以测试它是否是机器人,这与在线创建帐户时常用的 CAPTCHA 测试非常相似。通过提出 JavaScript 计算挑战之类的要求,可以缓解许多攻击。
\n其他阻止 HTTP 洪水攻击的途径包括使用 Web 应用程序防火墙 (WAF)、管理 IP 信誉数据库以跟踪和有选择地阻止恶意流量,以及由工程师进行动态分析。Cloudflare 具有超过 2000 万个互联网设备的规模优势,能够分析来自各种来源的流量并通过快速更新的 WAF 规则和其他防护策略来缓解潜在的攻击,从而消除应用程序层 DDoS 流量。
\n域名系统(DNS)服务器是互联网的“电话簿“;互联网设备通过这些服务器来查找特定 Web 服务器以便访问互联网内容。DNS Flood 攻击是一种分布式拒绝服务(DDoS)攻击,攻击者用大量流量淹没某个域的 DNS 服务器,以尝试中断该域的 DNS 解析。如果用户无法找到电话簿,就无法查找到用于调用特定资源的地址。通过中断 DNS 解析,DNS Flood 攻击将破坏网站、API 或 Web 应用程序响应合法流量的能力。很难将 DNS Flood 攻击与正常的大流量区分开来,因为这些大规模流量往往来自多个唯一地址,查询该域的真实记录,模仿合法流量。
\n域名系统的功能是将易于记忆的名称(例如 example.com)转换成难以记住的网站服务器地址(例如 192.168.0.1),因此成功攻击 DNS 基础设施将导致大多数人无法使用互联网。DNS Flood 攻击是一种相对较新的基于 DNS 的攻击,这种攻击是在高带宽物联网(IoT)僵尸网络(如 Mirai)兴起后激增的。DNS Flood 攻击使用 IP 摄像头、DVR 盒和其他 IoT 设备的高带宽连接直接淹没主要提供商的 DNS 服务器。来自 IoT 设备的大量请求淹没 DNS 提供商的服务,阻止合法用户访问提供商的 DNS 服务器。
\nDNS Flood 攻击不同于 DNS 放大攻击。与 DNS Flood 攻击不同,DNS 放大攻击反射并放大不安全 DNS 服务器的流量,以便隐藏攻击的源头并提高攻击的有效性。DNS 放大攻击使用连接带宽较小的设备向不安全的 DNS 服务器发送无数请求。这些设备对非常大的 DNS 记录发出小型请求,但在发出请求时,攻击者伪造返回地址为目标受害者。这种放大效果让攻击者能借助有限的攻击资源来破坏较大的目标。
\nDNS Flood 对传统上基于放大的攻击方法做出了改变。借助轻易获得的高带宽僵尸网络,攻击者现能针对大型组织发动攻击。除非被破坏的 IoT 设备得以更新或替换,否则抵御这些攻击的唯一方法是使用一个超大型、高度分布式的 DNS 系统,以便实时监测、吸收和阻止攻击流量。
\n在 TCP 重置攻击中,攻击者通过向通信的一方或双方发送伪造的消息,告诉它们立即断开连接,从而使通信双方连接中断。正常情况下,如果客户端收发现到达的报文段对于相关连接而言是不正确的,TCP 就会发送一个重置报文段,从而导致 TCP 连接的快速拆卸。
\nTCP 重置攻击利用这一机制,通过向通信方发送伪造的重置报文段,欺骗通信双方提前关闭 TCP 连接。如果伪造的重置报文段完全逼真,接收者就会认为它有效,并关闭 TCP 连接,防止连接被用来进一步交换信息。服务端可以创建一个新的 TCP 连接来恢复通信,但仍然可能会被攻击者重置连接。万幸的是,攻击者需要一定的时间来组装和发送伪造的报文,所以一般情况下这种攻击只对长连接有杀伤力,对于短连接而言,你还没攻击呢,人家已经完成了信息交换。
\n从某种意义上来说,伪造 TCP 报文段是很容易的,因为 TCP/IP 都没有任何内置的方法来验证服务端的身份。有些特殊的 IP 扩展协议(例如 IPSec
)确实可以验证身份,但并没有被广泛使用。客户端只能接收报文段,并在可能的情况下使用更高级别的协议(如 TLS
)来验证服务端的身份。但这个方法对 TCP 重置包并不适用,因为 TCP 重置包是 TCP 协议本身的一部分,无法使用更高级别的协议进行验证。
\n\n以下实验是在
\nOSX
系统中完成的,其他系统请自行测试。
现在来总结一下伪造一个 TCP 重置报文要做哪些事情:
\nACK
标志位置位 1 的报文段,并读取其 ACK
号。RST
标志位置为 1),其序列号等于上面截获的报文的 ACK
号。这只是理想情况下的方案,假设信息交换的速度不是很快。大多数情况下为了增加成功率,可以连续发送序列号不同的重置报文。为了实验简单,我们可以使用本地计算机通过 localhost
与自己通信,然后对自己进行 TCP 重置攻击。需要以下几个步骤:
下面正式开始实验。
\n\n\n建立 TCP 连接
\n
可以使用 netcat 工具来建立 TCP 连接,这个工具很多操作系统都预装了。打开第一个终端窗口,运行以下命令:
\nnc -nvl 8000\n
这个命令会启动一个 TCP 服务,监听端口为 8000
。接着再打开第二个终端窗口,运行以下命令:
nc 127.0.0.1 8000\n
该命令会尝试与上面的服务建立连接,在其中一个窗口输入一些字符,就会通过 TCP 连接发送给另一个窗口并打印出来。
\n\n\n\n嗅探流量
\n
编写一个攻击程序,使用 Python 网络库 scapy
来读取两个终端窗口之间交换的数据,并将其打印到终端上。代码比较长,下面为一部份,完整代码后台回复 TCP 攻击,代码的核心是调用 scapy
的嗅探方法:
这段代码告诉 scapy
在 lo0
网络接口上嗅探数据包,并记录所有 TCP 连接的详细信息。
lo0
(localhost)网络接口上进行监听。localhost
,且端口号为 8000
)的数据包。lfilter
规则的数据包。上面的例子只是将数据包打印到终端,下文将会修改函数来伪造重置报文。\n\n发送伪造的重置报文
\n
下面开始修改程序,发送伪造的 TCP 重置报文来进行 TCP 重置攻击。根据上面的解读,只需要修改 prn 函数就行了,让其检查数据包,提取必要参数,并利用这些参数来伪造 TCP 重置报文并发送。
\n例如,假设该程序截获了一个从(src_ip
, src_port
)发往 (dst_ip
, dst_port
)的报文段,该报文段的 ACK 标志位已置为 1,ACK 号为 100,000
。攻击程序接下来要做的是:
IP/Port
应该是截获数据包的目的 IP/Port
,反之亦然。RST
标志位置为 1,以表示这是一个重置报文。scapy
的 send
方法,将伪造的数据包发送给截获数据包的发送方。对于我的程序而言,只需将这一行取消注释,并注释这一行的上面一行,就可以全面攻击了。按照步骤 1 的方法设置 TCP 连接,打开第三个窗口运行攻击程序,然后在 TCP 连接的其中一个终端输入一些字符串,你会发现 TCP 连接被中断了!
\n\n\n进一步实验
\n
ACK
号完全相同。Wireshark
,监听 lo0 网络接口,并使用过滤器 ip.src == 127.0.0.1 && ip.dst == 127.0.0.1 && tcp.port == 8000
来过滤无关数据。你可以看到 TCP 连接的所有细节。猪八戒要向小蓝表白,于是写了一封信给小蓝,结果第三者小黑拦截到了这封信,把这封信进行了篡改,于是乎在他们之间进行搞破坏行动。这个马文才就是中间人,实施的就是中间人攻击。好我们继续聊聊什么是中间人攻击。
\n攻击中间人攻击英文名叫 Man-in-the-MiddleAttack,简称「MITM 攻击」。指攻击者与通讯的两端分别创建独立的联系,并交换其所收到的数据,使通讯的两端认为他们正在通过一个私密的连接与对方 直接对话,但事实上整个会话都被攻击者完全控制。我们画一张图:
\n\n从这张图可以看到,中间人其实就是攻击者。通过这种原理,有很多实现的用途,比如说,你在手机上浏览不健康网站的时候,手机就会提示你,此网站可能含有病毒,是否继续访问还是做其他的操作等等。
\n举个例子,我和公司签了一个一份劳动合同,一人一份合同。不晓得哪个可能改了合同内容,不知道真假了,怎么搞?只好找专业的机构来鉴定,自然就要花钱。
\n在安全领域有句话:我们没有办法杜绝网络犯罪,只好想办法提高网络犯罪的成本。既然没法杜绝这种情况,那我们就想办法提高作案的成本,今天我们就简单了解下基本的网络安全知识,也是面试中的高频面试题了。
\n为了避免双方说活不算数的情况,双方引入第三家机构,将合同原文给可信任的第三方机构,只要这个机构不监守自盗,合同就相对安全。
\n如果第三方机构内部不严格或容易出现纰漏?
\n虽然我们将合同原文给第三方机构了,为了防止内部人员的更改,需要采取什么措施呢
\n一种可行的办法是引入 摘要算法 。即合同和摘要一起,为了简单的理解摘要。大家可以想象这个摘要为一个函数,这个函数对原文进行了加密,会产生一个唯一的散列值,一旦原文发生一点点变化,那么这个散列值将会变化。
\n目前比较常用的加密算法有消息摘要算法和安全散列算法(SHA)。MD5 是将任意长度的文章转化为一个 128 位的散列值,可是在 2004 年,MD5 被证实了容易发生碰撞,即两篇原文产生相同的摘要。这样的话相当于直接给黑客一个后门,轻松伪造摘要。
\n所以在大部分的情况下都会选择 SHA 算法 。
\n出现内鬼了怎么办?
\n看似很安全的场面了,理论上来说杜绝了篡改合同的做法。主要某个员工同时具有修改合同和摘要的权利,那搞事儿就是时间的问题了,毕竟没哪个系统可以完全的杜绝员工接触敏感信息,除非敏感信息都不存在。所以能不能考虑将合同和摘要分开存储呢
\n那如何确保员工不会修改合同呢?
\n这确实蛮难的,不过办法总比困难多。我们将合同放在双方手中,摘要放在第三方机构,篡改难度进一步加大
\n那么员工万一和某个用户串通好了呢?
\n看来放在第三方的机构还是不好使,同样存在不小风险。所以还需要寻找新的方案,这就出现了 数字签名和证书。
\n同样的,举个例子。Sum 和 Mike 两个人签合同。Sum 首先用 SHA 算法计算合同的摘要,然后用自己私钥将摘要加密,得到数字签名。Sum 将合同原文、签名,以及公钥三者都交给 Mike
\n\n如果 Sum 想要证明合同是 Mike 的,那么就要使用 Mike 的公钥,将这个签名解密得到摘要 x,然后 Mike 计算原文的 sha 摘要 Y,随后对比 x 和 y,如果两者相等,就认为数据没有被篡改
\n在这样的过程中,Mike 是不能更改 Sum 的合同,因为要修改合同不仅仅要修改原文还要修改摘要,修改摘要需要提供 Mike 的私钥,私钥即 Sum 独有的密码,公钥即 Sum 公布给他人使用的密码
\n总之,公钥加密的数据只能私钥可以解密。私钥加密的数据只有公钥可以解密,这就是 非对称加密 。
\n隐私保护?不是吓唬大家,信息是透明的兄 die,不过尽量去维护个人的隐私吧,今天学习对称加密和非对称加密。
\n大家先读读这个字\"钥\",是读\"yao\",我以前也是,其实读\"yue\"
\n对称加密,顾名思义,加密方与解密方使用同一钥匙(秘钥)。具体一些就是,发送方通过使用相应的加密算法和秘钥,对将要发送的信息进行加密;对于接收方而言,使用解密算法和相同的秘钥解锁信息,从而有能力阅读信息。
\n\nDES
\nDES 使用的密钥表面上是 64 位的,然而只有其中的 56 位被实际用于算法,其余 8 位可以被用于奇偶校验,并在算法中被丢弃。因此,DES 的有效密钥长度为 56 位,通常称 DES 的密钥长度为 56 位。假设秘钥为 56 位,采用暴力破 Jie 的方式,其秘钥个数为 2 的 56 次方,那么每纳秒执行一次解密所需要的时间差不多 1 年的样子。当然,没人这么干。DES 现在已经不是一种安全的加密方法,主要因为它使用的 56 位密钥过短。
\n\nIDEA
\n国际数据加密算法(International Data Encryption Algorithm)。秘钥长度 128 位,优点没有专利的限制。
\nAES
\n当 DES 被破解以后,没过多久推出了 AES 算法,提供了三种长度供选择,128 位、192 位和 256,为了保证性能不受太大的影响,选择 128 即可。
\nSM1 和 SM4
\n之前几种都是国外的,我们国内自行研究了国密 SM1和 SM4。其中 S 都属于国家标准,算法公开。优点就是国家的大力支持和认可
\n总结:
\n\n在对称加密中,发送方与接收方使用相同的秘钥。那么在非对称加密中则是发送方与接收方使用的不同的秘钥。其主要解决的问题是防止在秘钥协商的过程中发生泄漏。比如在对称加密中,小蓝将需要发送的消息加密,然后告诉你密码是 123balala,ok,对于其他人而言,很容易就能劫持到密码是 123balala。那么在非对称的情况下,小蓝告诉所有人密码是 123balala,对于中间人而言,拿到也没用,因为没有私钥。所以,非对称密钥其实主要解决了密钥分发的难题。如下图
\n\n其实我们经常都在使用非对称加密,比如使用多台服务器搭建大数据平台 hadoop,为了方便多台机器设置免密登录,是不是就会涉及到秘钥分发。再比如搭建 docker 集群也会使用相关非对称加密算法。
\n常见的非对称加密算法:
\nRSA(RSA 加密算法,RSA Algorithm):优势是性能比较快,如果想要较高的加密难度,需要很长的秘钥。
\nECC:基于椭圆曲线提出。是目前加密强度最高的非对称加密算法
\nSM2:同样基于椭圆曲线问题设计。最大优势就是国家认可和大力支持。
\n总结:
\n\n这个大家应该更加熟悉了,比如我们平常使用的 MD5 校验,在很多时候,我并不是拿来进行加密,而是用来获得唯一性 ID。在做系统的过程中,存储用户的各种密码信息,通常都会通过散列算法,最终存储其散列值。
\nMD5
\nMD5 可以用来生成一个 128 位的消息摘要,它是目前应用比较普遍的散列算法,具体的应用场景你可以自行 参阅。虽然,因为算法的缺陷,它的唯一性已经被破解了,但是大部分场景下,这并不会构成安全问题。但是,如果不是长度受限(32 个字符),我还是不推荐你继续使用 MD5 的。
\nSHA
\n安全散列算法。SHA 分为 SHA1 和 SH2 两个版本。该算法的思想是接收一段明文,然后以一种不可逆的方式将它转换成一段(通常更小)密文,也可以简单的理解为取一串输入码(称为预映射或信息),并把它们转化为长度较短、位数固定的输出序列即散列值(也称为信息摘要或信息认证代码)的过程。
\nSM3
\n国密算法SM3。加密强度和 SHA-256 想不多。主要是收到国家的支持。
\n总结:
\n\n大部分情况下使用对称加密,具有比较不错的安全性。如果需要分布式进行秘钥分发,考虑非对称。如果不需要可逆计算则散列算法。 因为这段时间有这方面需求,就看了一些这方面的资料,入坑信息安全,就怕以后洗发水都不用买。谢谢大家查看!
\n问题还有,此时如果 Sum 否认给过 Mike 的公钥和合同,不久 gg 了
\n所以需要 Sum 过的话做过的事儿需要足够的信誉,这就引入了 第三方机构和证书机制 。
\n证书之所以会有信用,是因为证书的签发方拥有信用。所以如果 Sum 想让 Mike 承认自己的公钥,Sum 不会直接将公钥给 Mike ,而是提供由第三方机构,含有公钥的证书。如果 Mike 也信任这个机构,法律都认可,那 ik,信任关系成立
\n\n如上图所示,Sum 将自己的申请提交给机构,产生证书的原文。机构用自己的私钥签名 Sum 的申请原文(先根据原文内容计算摘要,再用私钥加密),得到带有签名信息的证书。Mike 拿到带签名信息的证书,通过第三方机构的公钥进行解密,获得 Sum 证书的摘要、证书的原文。有了 Sum 证书的摘要和原文,Mike 就可以进行验签。验签通过,Mike 就可以确认 Sum 的证书的确是第三方机构签发的。
\n用上面这样一个机制,合同的双方都无法否认合同。这个解决方案的核心在于需要第三方信用服务机构提供信用背书。这里产生了一个最基础的信任链,如果第三方机构的信任崩溃,比如被黑客攻破,那整条信任链条也就断裂了
\n为了让这个信任条更加稳固,就需要环环相扣,打造更长的信任链,避免单点信任风险
\n\n上图中,由信誉最好的根证书机构提供根证书,然后根证书机构去签发二级机构的证书;二级机构去签发三级机构的证书;最后有由三级机构去签发 Sum 证书。
\n如果要验证 Sum 证书的合法性,就需要用三级机构证书中的公钥去解密 Sum 证书的数字签名。
\n如果要验证三级机构证书的合法性,就需要用二级机构的证书去解密三级机构证书的数字签名。
\n如果要验证二级结构证书的合法性,就需要用根证书去解密。
\n以上,就构成了一个相对长一些的信任链。如果其中一方想要作弊是非常困难的,除非链条中的所有机构同时联合起来,进行欺诈。
\n既然知道了中间人攻击的原理也知道了他的危险,现在我们看看如何避免。相信我们都遇到过下面这种状况:
\n\n出现这个界面的很多情况下,都是遇到了中间人攻击的现象,需要对安全证书进行及时地监测。而且大名鼎鼎的 github 网站,也曾遭遇过中间人攻击:
\n想要避免中间人攻击的方法目前主要有两个:
\n通过上面的描述,总之即好多种攻击都是 DDOS 攻击,所以简单总结下这个攻击相关内容。
\n其实,像全球互联网各大公司,均遭受过大量的 DDoS。
\n2018 年,GitHub 在一瞬间遭到高达 1.35Tbps 的带宽攻击。这次 DDoS 攻击几乎可以堪称是互联网有史以来规模最大、威力最大的 DDoS 攻击了。在 GitHub 遭到攻击后,仅仅一周后,DDoS 攻击又开始对 Google、亚马逊甚至 Pornhub 等网站进行了 DDoS 攻击。后续的 DDoS 攻击带宽最高也达到了 1Tbps。
\nDDos 全名 Distributed Denial of Service,翻译成中文就是分布式拒绝服务。指的是处于不同位置的多个攻击者同时向一个或数个目标发动攻击,是一种分布的、协同的大规模攻击方式。单一的 DoS 攻击一般是采用一对一方式的,它利用网络协议和操作系统的一些缺陷,采用欺骗和伪装的策略来进行网络攻击,使网站服务器充斥大量要求回复的信息,消耗网络带宽或系统资源,导致网络或系统不胜负荷以至于瘫痪而停止提供正常的网络服务。
\n\n\n举个例子
\n
我开了一家有五十个座位的重庆火锅店,由于用料上等,童叟无欺。平时门庭若市,生意特别红火,而对面二狗家的火锅店却无人问津。二狗为了对付我,想了一个办法,叫了五十个人来我的火锅店坐着却不点菜,让别的客人无法吃饭。
\n上面这个例子讲的就是典型的 DDoS 攻击,一般来说是指攻击者利用“肉鸡”对目标网站在较短的时间内发起大量请求,大规模消耗目标网站的主机资源,让它无法正常服务。在线游戏、互联网金融等领域是 DDoS 攻击的高发行业。
\n攻击方式很多,比如 ICMP Flood、UDP Flood、NTP Flood、SYN Flood、CC 攻击、DNS Query Flood等等。
\n还是拿开的重庆火锅店举例,高防服务器就是我给重庆火锅店增加了两名保安,这两名保安可以让保护店铺不受流氓骚扰,并且还会定期在店铺周围巡逻防止流氓骚扰。
\n高防服务器主要是指能独立硬防御 50Gbps 以上的服务器,能够帮助网站拒绝服务攻击,定期扫描网络主节点等,这东西是不错,就是贵~
\n面对火锅店里面的流氓,我一怒之下将他们拍照入档,并禁止他们踏入店铺,但是有的时候遇到长得像的人也会禁止他进入店铺。这个就是设置黑名单,此方法秉承的就是“错杀一千,也不放一百”的原则,会封锁正常流量,影响到正常业务。
\nDDos 清洗,就是我发现客人进店几分钟以后,但是一直不点餐,我就把他踢出店里。
\nDDoS 清洗会对用户请求数据进行实时监控,及时发现 DOS 攻击等异常流量,在不影响正常业务开展的情况下清洗掉这些异常流量。
\nCDN 加速,我们可以这么理解:为了减少流氓骚扰,我干脆将火锅店开到了线上,承接外卖服务,这样流氓找不到店在哪里,也耍不来流氓了。
\n在现实中,CDN 服务将网站访问流量分配到了各个节点中,这样一方面隐藏网站的真实 IP,另一方面即使遭遇 DDoS 攻击,也可以将流量分散到各个节点中,防止源站崩溃。
\n\n\n题目来源于:牛客题霸 - SQL 必知必会
\n
SELECT
用于从数据库中查询数据。
现有表 Customers
如下:
| cust_id |
\n|
\n\n本文整理完善自下面这两份资料:
\n\n
\n- SQL 语法速成手册
\n- MySQL 超全教程
\n
数据库(database)
- 保存有组织的数据的容器(通常是一个文件或一组文件)。数据表(table)
- 某种特定类型数据的结构化清单。模式(schema)
- 关于数据库和表的布局及特性的信息。模式定义了数据在表中如何存储,包含存储什么样的数据,数据如何分解,各部分信息如何命名等信息。数据库和表都有模式。列(column)
- 表中的一个字段。所有表都是由一个或多个列组成的。行(row)
- 表中的一个记录。主键(primary key)
- 一列(或一组列),其值能够唯一标识表中每一行。SQL(Structured Query Language),标准 SQL 由 ANSI 标准委员会管理,从而称为 ANSI SQL。各个 DBMS 都有自己的实现,如 PL/SQL、Transact-SQL 等。
\nSQL 语法结构包括:
\n子句
- 是语句和查询的组成成分。(在某些情况下,这些都是可选的。)表达式
- 可以产生任何标量值,或由列和行的数据库表谓词
- 给需要评估的 SQL 三值逻辑(3VL)(true/false/unknown)或布尔真值指定条件,并限制语句和查询的效果,或改变程序流程。查询
- 基于特定条件检索数据。这是 SQL 的一个重要组成部分。语句
- 可以持久地影响纲要和数据,也可以控制数据库事务、程序流程、连接、会话或诊断。SELECT
与 select
、Select
是相同的。;
)分隔。SQL 语句可以写成一行,也可以分写为多行。
\n-- 一行 SQL 语句\n\nUPDATE user SET username='robot', password='robot' WHERE username = 'root';\n\n-- 多行 SQL 语句\nUPDATE user\nSET username='robot', password='robot'\nWHERE username = 'root';\n
SQL 支持三种注释:
\n## 注释1\n-- 注释2\n/* 注释3 */\n
数据定义语言(Data Definition Language,DDL)是 SQL 语言集中负责数据结构定义与数据库对象定义的语言。
\nDDL 的主要功能是定义数据库对象。
\nDDL 的核心指令是 CREATE
、ALTER
、DROP
。
数据操纵语言(Data Manipulation Language, DML)是用于数据库操作,对数据库其中的对象和数据运行访问工作的编程语句。
\nDML 的主要功能是 访问数据,因此其语法都是以读写数据库为主。
\nDML 的核心指令是 INSERT
、UPDATE
、DELETE
、SELECT
。这四个指令合称 CRUD(Create, Read, Update, Delete),即增删改查。
事务控制语言 (Transaction Control Language, TCL) 用于管理数据库中的事务。这些用于管理由 DML 语句所做的更改。它还允许将语句分组为逻辑事务。
\nTCL 的核心指令是 COMMIT
、ROLLBACK
。
数据控制语言 (Data Control Language, DCL) 是一种可对数据访问权进行控制的指令,它可以控制特定用户账户对数据表、查看表、预存程序、用户自定义函数等数据库对象的控制权。
\nDCL 的核心指令是 GRANT
、REVOKE
。
DCL 以控制用户的访问权限为主,因此其指令作法并不复杂,可利用 DCL 控制的权限有:CONNECT
、SELECT
、INSERT
、UPDATE
、DELETE
、EXECUTE
、USAGE
、REFERENCES
。
根据不同的 DBMS 以及不同的安全性实体,其支持的权限控制也有所不同。
\n我们先来介绍 DML 语句用法。 DML 的主要功能是读写数据库实现增删改查。
\n增删改查,又称为 CRUD,数据库基本操作中的基本操作。
\nINSERT INTO
语句用于向表中插入新记录。
插入完整的行
\n# 插入一行\nINSERT INTO user\nVALUES (10, 'root', 'root', 'xxxx@163.com');\n# 插入多行\nINSERT INTO user\nVALUES (10, 'root', 'root', 'xxxx@163.com'), (12, 'user1', 'user1', 'xxxx@163.com'), (18, 'user2', 'user2', 'xxxx@163.com');\n
插入行的一部分
\nINSERT INTO user(username, password, email)\nVALUES ('admin', 'admin', 'xxxx@163.com');\n
插入查询出来的数据
\nINSERT INTO user(username)\nSELECT name\nFROM account;\n
UPDATE
语句用于更新表中的记录。
UPDATE user\nSET username='robot', password='robot'\nWHERE username = 'root';\n
DELETE
语句用于删除表中的记录。TRUNCATE TABLE
可以清空表,也就是删除所有行。删除表中的指定数据
\nDELETE FROM user\nWHERE username = 'robot';\n
清空表中的数据
\nTRUNCATE TABLE user;\n
SELECT
语句用于从数据库中查询数据。
DISTINCT
用于返回唯一不同的值。它作用于所有列,也就是说所有列的值都相同才算相同。
LIMIT
限制返回的行数。可以有两个参数,第一个参数为起始行,从 0 开始;第二个参数为返回的总行数。
ASC
:升序(默认)DESC
:降序查询单列
\nSELECT prod_name\nFROM products;\n
查询多列
\nSELECT prod_id, prod_name, prod_price\nFROM products;\n
查询所有列
\nSELECT *\nFROM products;\n
查询不同的值
\nSELECT DISTINCT\nvend_id FROM products;\n
限制查询结果
\n-- 返回前 5 行\nSELECT * FROM mytable LIMIT 5;\nSELECT * FROM mytable LIMIT 0, 5;\n-- 返回第 3 ~ 5 行\nSELECT * FROM mytable LIMIT 2, 3;\n
order by
用于对结果集按照一个列或者多个列进行排序。默认按照升序对记录进行排序,如果需要按照降序对记录进行排序,可以使用 desc
关键字。
order by
对多列排序的时候,先排序的列放前面,后排序的列放后面。并且,不同的列可以有不同的排序规则。
SELECT * FROM products\nORDER BY prod_price DESC, prod_name ASC;\n
group by
:
group by
子句将记录分组到汇总行中。group by
为每个组返回一个记录。group by
通常还涉及聚合count
,max
,sum
,avg
等。group by
可以按一列或多列进行分组。group by
按分组字段进行排序后,order by
可以以汇总字段来进行排序。分组
\nSELECT cust_name, COUNT(cust_address) AS addr_num\nFROM Customers GROUP BY cust_name;\n
分组后排序
\nSELECT cust_name, COUNT(cust_address) AS addr_num\nFROM Customers GROUP BY cust_name\nORDER BY cust_name DESC;\n
having
:
having
用于对汇总的 group by
结果进行过滤。having
一般都是和 group by
连用。where
和 having
可以在相同的查询中。使用 WHERE 和 HAVING 过滤数据
\nSELECT cust_name, COUNT(*) AS num\nFROM Customers\nWHERE cust_email IS NOT NULL\nGROUP BY cust_name\nHAVING COUNT(*) >= 1;\n
having
vs where
:
where
:过滤过滤指定的行,后面不能加聚合函数(分组函数)。where
在group by
前。having
:过滤分组,一般都是和 group by
连用,不能单独使用。having
在 group by
之后。子查询是嵌套在较大查询中的 SQL 查询,也称内部查询或内部选择,包含子查询的语句也称为外部查询或外部选择。简单来说,子查询就是指将一个 select
查询(子查询)的结果作为另一个 SQL 语句(主查询)的数据来源或者判断条件。
子查询可以嵌入 SELECT
、INSERT
、UPDATE
和 DELETE
语句中,也可以和 =
、<
、>
、IN
、BETWEEN
、EXISTS
等运算符一起使用。
子查询常用在 WHERE
子句和 FROM
子句后边:
WHERE
子句时,根据不同的运算符,子查询可以返回单行单列、多行单列、单行多列数据。子查询就是要返回能够作为 WHERE
子句查询条件的值。FROM
子句时,一般返回多行多列数据,相当于返回一张临时表,这样才符合 FROM
后面是表的规则。这种做法能够实现多表联合查询。\n\n注意:MYSQL 数据库从 4.1 版本才开始支持子查询,早期版本是不支持的。
\n
用于 WHERE
子句的子查询的基本语法如下:
select column_name [, column_name ]\nfrom table1 [, table2 ]\nwhere column_name operator\n (select column_name [, column_name ]\n from table1 [, table2 ]\n [where])\n
( )
内。operator
表示用于 where 子句的运算符。用于 FROM
子句的子查询的基本语法如下:
select column_name [, column_name ]\nfrom (select column_name [, column_name ]\n from table1 [, table2 ]\n [where]) as temp_table_name\nwhere condition\n
用于 FROM
的子查询返回的结果相当于一张临时表,所以需要使用 AS 关键字为该临时表起一个名字。
子查询的子查询
\nSELECT cust_name, cust_contact\nFROM customers\nWHERE cust_id IN (SELECT cust_id\n FROM orders\n WHERE order_num IN (SELECT order_num\n FROM orderitems\n WHERE prod_id = 'RGAN01'));\n
内部查询首先在其父查询之前执行,以便可以将内部查询的结果传递给外部查询。执行过程可以参考下图:
\n\nWHERE
子句用于过滤记录,即缩小访问数据的范围。WHERE
后跟一个返回 true
或 false
的条件。WHERE
可以与 SELECT
,UPDATE
和 DELETE
一起使用。WHERE
子句中使用的操作符。| 运算符 | 描述 |
\n|
\n\n这部分内容主要根据 Gradle 官方文档整理,做了对应的删减,主要保留比较重要的部分,不涉及实战,主要是一些重要概念的介绍。
\n
Gradle 这部分内容属于可选内容,可以根据自身需求决定是否学习,目前国内还是使用 Maven 普遍一些。
\nGradle 官方文档是这样介绍的 Gradle 的:
\n\n\nGradle is an open-source build automation tool flexible enough to build almost any type of software. Gradle makes few assumptions about what you’re trying to build or how to build it. This makes Gradle particularly flexible.
\nGradle 是一个开源的构建自动化工具,它足够灵活,可以构建几乎任何类型的软件。Gradle 对你要构建什么或者如何构建它做了很少的假设。这使得 Gradle 特别灵活。
\n
简单来说,Gradle 就是一个运行在 JVM 上的自动化的项目构建工具,用来帮助我们自动构建项目。
\n对于开发者来说,Gradle 的主要作用主要有 3 个:
\nGradle 构建脚本是使用 Groovy 或 Kotlin 语言编写的,表达能力非常强,也足够灵活。
\nGradle 是运行在 JVM 上的一个程序,它可以使用 Groovy 来编写构建脚本。
\nGroovy 是运行在 JVM 上的脚本语言,是基于 Java 扩展的动态语言,它的语法和 Java 非常的相似,可以使用 Java 的类库。Groovy 可以用于面向对象编程,也可以用作纯粹的脚本语言。在语言的设计上它吸纳了 Java、Python、Ruby 和 Smalltalk 语言的优秀特性,比如动态类型转换、闭包和元编程支持。
\n我们可以用学习 Java 的方式去学习 Groovy ,学习成本相对来说还是比较低的,即使开发过程中忘记 Groovy 语法,也可以用 Java 语法继续编码。
\n基于 JVM 的语言有很多种比如 Groovy,Kotlin,Java,Scala,他们最终都会编译生成 Java 字节码文件并在 JVM 上运行。
\nGradle 是新一代的构建系统,具有高效和灵活等诸多优势,广泛用于 Java 开发。不仅 Android 将其作为官方构建系统, 越来越多的 Java 项目比如 Spring Boot 也慢慢迁移到 Gradle。
\nGradle 官方文档是这样介绍的 Gradle Wrapper 的:
\n\n\nThe recommended way to execute any Gradle build is with the help of the Gradle Wrapper (in short just “Wrapper”). The Wrapper is a script that invokes a declared version of Gradle, downloading it beforehand if necessary. As a result, developers can get up and running with a Gradle project quickly without having to follow manual installation processes saving your company time and money.
\n执行 Gradle 构建的推荐方法是借助 Gradle Wrapper(简而言之就是“Wrapper”)。Wrapper 它是一个脚本,调用了已经声明的 Gradle 版本,如果需要的话,可以预先下载它。因此,开发人员可以快速启动并运行 Gradle 项目,而不必遵循手动安装过程,从而为公司节省时间和金钱。
\n
我们可以称 Gradle Wrapper 为 Gradle 包装器,它将 Gradle 再次包装,让所有的 Gradle 构建方法在 Gradle 包装器的帮助下运行。
\nGradle Wrapper 的工作流程图如下(图源Gradle Wrapper 官方文档介绍):
\n\n整个流程主要分为下面 3 步:
\nGradle Wrapper 会给我们带来下面这些好处:
\n如果想要生成 Gradle Wrapper 的话,需要本地配置好 Gradle 环境变量。Gradle 中已经内置了内置了 Wrapper Task,在项目根目录执行执行gradle wrapper
命令即可帮助我们生成 Gradle Wrapper。
执行命令 gradle wrapper
命令时可以指定一些参数来控制 wrapper 的生成。具体有如下两个配置参数:
--gradle-version
用于指定使用的 Gradle 的版本--gradle-distribution-url
用于指定下载 Gradle 版本的 URL,该值的规则是 http://services.gradle.org/distributions/gradle-${gradleVersion}-bin.zip
执行gradle wrapper
命令之后,Gradle Wrapper 就生成完成了,项目根目录中生成如下文件:
├── gradle\n│ └── wrapper\n│ ├── gradle-wrapper.jar\n│ └── gradle-wrapper.properties\n├── gradlew\n└── gradlew.bat\n
每个文件的含义如下:
\ngradle-wrapper.jar
:包含了 Gradle 运行时的逻辑代码。gradle-wrapper.properties
:定义了 Gradle 的版本号和 Gradle 运行时的行为属性。gradlew
:Linux 平台下,用于执行 Gralde 命令的包装器脚本。gradlew.bat
:Windows 平台下,用于执行 Gralde 命令的包装器脚本。gradle-wrapper.properties
文件的内容如下:
distributionBase=GRADLE_USER_HOME\ndistributionPath=wrapper/dists\ndistributionUrl=https\\://services.gradle.org/distributions/gradle-6.0.1-bin.zip\nzipStoreBase=GRADLE_USER_HOME\nzipStorePath=wrapper/dists\n
distributionBase
:Gradle 解包后存储的父目录。distributionPath
:distributionBase
指定目录的子目录。distributionBase+distributionPath
就是 Gradle 解包后的存放的具体目录。distributionUrl
:Gradle 指定版本的压缩包下载地址。zipStoreBase
:Gradle 压缩包下载后存储父目录。zipStorePath
:zipStoreBase
指定目录的子目录。zipStoreBase+zipStorePath
就是 Gradle 压缩包的存放位置。更新 Gradle Wrapper 有 2 种方式:
\ndistributionUrl
字段,然后执行 Gradle 命令。gradlew wrapper –-gradle-version [version]
。下面的命令会将 Gradle 版本升级为 7.6。
\ngradlew wrapper --gradle-version 7.6\n
gradle-wrapper.properties
文件中的 distributionUrl
属性也发生了改变。
distributionUrl=https\\://services.gradle.org/distributions/gradle-7.6-all.zip\n
Gradle 已经内置了 Wrapper Task,因此构建 Gradle Wrapper 会生成 Gradle Wrapper 的属性文件,这个属性文件可以通过自定义 Wrapper Task 来设置。比如我们想要修改要下载的 Gralde 版本为 7.6,可以这么设置:
\ntask wrapper(type: Wrapper) {\n gradleVersion = '7.6'\n}\n
也可以设置 Gradle 发行版压缩包的下载地址和 Gradle 解包后的本地存储路径等配置。
\ntask wrapper(type: Wrapper) {\n gradleVersion = '7.6'\n distributionUrl = '../../gradle-7.6-bin.zip'\n distributionPath=wrapper/dists\n}\n
distributionUrl
属性可以设置为本地的项目目录,你也可以设置为网络地址。
在 Gradle 中,任务(Task)是构建执行的单个工作单元。
\nGradle 的构建是基于 Task 进行的,当你运行项目的时候,实际就是在执行了一系列的 Task 比如编译 Java 源码的 Task、生成 jar 文件的 Task。
\nTask 的声明方式如下(还有其他几种声明方式):
\n// 声明一个名字为 helloTask 的 Task\ntask helloTask{\n doLast{\n println \"Hello\"\n }\n}\n
创建一个 Task 后,可以根据需要给 Task 添加不同的 Action,上面的“doLast”就是给队列尾增加一个 Action。
\n //在Action 队列头部添加Action\n Task doFirst(Action<? super Task> action);\n Task doFirst(Closure action);\n\n //在Action 队列尾部添加Action\n Task doLast(Action<? super Task> action);\n Task doLast(Closure action);\n\n //删除所有的Action\n Task deleteAllActions();\n
一个 Task 中可以有多个 Acton,从队列头部开始向队列尾部执行 Acton。
\nAction 代表的是一个个函数、方法,每个 Task 都是一堆 Action 按序组成的执行图。
\nTask 声明依赖的关键字是dependsOn
,支持声明一个或多个依赖:
task first {\n doLast {\n println \"+++++first+++++\"\n }\n}\ntask second {\n doLast {\n println \"+++++second+++++\"\n }\n}\n\n// 指定多个 task 依赖\ntask print(dependsOn :[second,first]) {\n doLast {\n logger.quiet \"指定多个task依赖\"\n }\n}\n\n// 指定一个 task 依赖\ntask third(dependsOn : print) {\n doLast {\n println '+++++third+++++'\n }\n}\n
执行 Task 之前,会先执行它的依赖 Task。
\n我们还可以设置默认 Task,脚本中我们不调用默认 Task ,也会执行。
\ndefaultTasks 'clean', 'run'\n\ntask clean {\n doLast {\n println 'Default Cleaning!'\n }\n}\n\ntask run {\n doLast {\n println 'Default Running!'\n }\n}\n
Gradle 本身也内置了很多 Task 比如 copy(复制文件)、delete(删除文件)。
\ntask deleteFile(type: Delete) {\n delete \"C:\\\\Users\\\\guide\\\\Desktop\\\\test\"\n}\n
Gradle 提供的是一套核心的构建机制,而 Gradle 插件则是运行在这套机制上的一些具体构建逻辑,其本质上和 .gradle
文件是相同。你可以将 Gradle 插件看作是封装了一系列 Task 并执行的工具。
Gradle 插件主要分为两类:
\n虽然 Gradle 插件与 .gradle 文件本质上没有区别,.gradle
文件也能实现 Gradle 插件类似的功能。但是,Gradle 插件使用了独立模块封装构建逻辑,无论是从开发开始使用来看,Gradle 插件的整体体验都更友好。
Gradle 构建的生命周期有三个阶段:初始化阶段,配置阶段和运行阶段。
\n\n在初始化阶段与配置阶段之间、配置阶段结束之后、执行阶段结束之后,我们都可以加一些定制化的 Hook。
\n\nGradle 支持单项目和多项目构建。在初始化阶段,Gradle 确定哪些项目将参与构建,并为每个项目创建一个 Project 实例 。本质上也就是执行 settings.gradle
脚本,从而读取整个项目中有多少个 Project 实例。
在配置阶段,Gradle 会解析每个工程的 build.gradle
文件,创建要执行的任务子集和确定各种任务之间的关系,以供执行阶段按照顺序执行,并对任务的做一些初始化配置。
每个 build.gradle
对应一个 Project 对象,配置阶段执行的代码包括 build.gradle
中的各种语句、闭包以及 Task 中的配置语句。
在配置阶段结束后,Gradle 会根据 Task 的依赖关系会创建一个 有向无环图 。
\n在运行阶段,Gradle 根据配置阶段创建和配置的要执行的任务子集,执行任务。
\n\n\n作者:飞天小牛肉
\n\n
众所周知,自增主键可以让聚集索引尽量地保持递增顺序插入,避免了随机查询,从而提高了查询效率。
\n但实际上,MySQL 的自增主键并不能保证一定是连续递增的。
\n下面举个例子来看下,如下所示创建一张表:
\n\n使用 insert into test_pk values(null, 1, 1)
插入一行数据,再执行 show create table
命令来看一下表的结构定义:
上述表的结构定义存放在后缀名为 .frm
的本地文件中,在 MySQL 安装目录下的 data 文件夹下可以找到这个 .frm
文件:
从上述表结构可以看到,表定义里面出现了一个 AUTO_INCREMENT=2
,表示下一次插入数据时,如果需要自动生成自增值,会生成 id = 2。
但需要注意的是,自增值并不会保存在这个表结构也就是 .frm
文件中,不同的引擎对于自增值的保存策略不同:
1)MyISAM 引擎的自增值保存在数据文件中
\n2)InnoDB 引擎的自增值,其实是保存在了内存里,并没有持久化。第一次打开表的时候,都会去找自增值的最大值 max(id)
,然后将 max(id)+1
作为这个表当前的自增值。
举个例子:我们现在表里当前数据行里最大的 id 是 1,AUTO_INCREMENT=2,对吧。这时候,我们删除 id=1 的行,AUTO_INCREMENT 还是 2。
\n\n但如果马上重启 MySQL 实例,重启后这个表的 AUTO_INCREMENT 就会变成 1。 也就是说,MySQL 重启可能会修改一个表的 AUTO_INCREMENT 的值。
\n\n\n以上,是在我本地 MySQL 5.x 版本的实验,实际上,到了 MySQL 8.0 版本后,自增值的变更记录被放在了 redo log 中,提供了自增值持久化的能力 ,也就是实现了“如果发生重启,表的自增值可以根据 redo log 恢复为 MySQL 重启前的值”
\n也就是说对于上面这个例子来说,重启实例后这个表的 AUTO_INCREMENT 仍然是 2。
\n理解了 MySQL 自增值到底保存在哪里以后,我们再来看看自增值的修改机制,并以此引出第一种自增值不连续的场景。
\n在 MySQL 里面,如果字段 id 被定义为 AUTO_INCREMENT,在插入一行数据的时候,自增值的行为如下:
\n根据要插入的值和当前自增值的大小关系,自增值的变更结果也会有所不同。假设某次要插入的值是 insert_num
,当前的自增值是 autoIncrement_num
:
insert_num < autoIncrement_num
,那么这个表的自增值不变insert_num >= autoIncrement_num
,就需要把当前自增值修改为新的自增值也就是说,如果插入的 id 是 100,当前的自增值是 90,insert_num >= autoIncrement_num
,那么自增值就会被修改为新的自增值即 101
一定是这样吗?
\n非也~
\n了解过分布式 id 的小伙伴一定知道,为了避免两个库生成的主键发生冲突,我们可以让一个库的自增 id 都是奇数,另一个库的自增 id 都是偶数
\n这个奇数偶数其实是通过 auto_increment_offset
和 auto_increment_increment
这两个参数来决定的,这俩分别用来表示自增的初始值和步长,默认值都是 1。
所以,上面的例子中生成新的自增值的步骤实际是这样的:从 auto_increment_offset
开始,以 auto_increment_increment
为步长,持续叠加,直到找到第一个大于 100 的值,作为新的自增值。
所以,这种情况下,自增值可能会是 102,103 等等之类的,就会导致不连续的主键 id。
\n更遗憾的是,即使在自增初始值和步长这两个参数都设置为 1 的时候,自增主键 id 也不一定能保证主键是连续的
\n举个例子,我们现在往表里插入一条 (null,1,1) 的记录,生成的主键是 1,AUTO_INCREMENT= 2,对吧
\n\n这时我再执行一条插入 (null,1,1)
的命令,很显然会报错 Duplicate entry
,因为我们设置了一个唯一索引字段 a
:
但是,你会惊奇的发现,虽然插入失败了,但自增值仍然从 2 增加到了 3!
\n这是为啥?
\n我们来分析下这个 insert 语句的执行流程:
\ntest_pk
当前的自增值 2;可以看到,自增值修改的这个操作,是在真正执行插入数据的操作之前。
\n这个语句真正执行的时候,因为碰到唯一键 a 冲突,所以 id = 2 这一行并没有插入成功,但也没有将自增值再改回去。所以,在这之后,再插入新的数据行时,拿到的自增 id 就是 3。也就是说,出现了自增主键不连续的情况。
\n至此,我们已经罗列了两种自增主键不连续的情况:
\n除此之外,事务回滚也会导致这种情况
\n我们现在表里有一行 (1,1,1)
的记录,AUTO_INCREMENT = 3:
我们先插入一行数据 (null, 2, 2)
,也就是 (3, 2, 2) 嘛,并且 AUTO_INCREMENT 变为 4:
再去执行这样一段 SQL:
\n\n虽然我们插入了一条 (null, 3, 3) 记录,但是使用 rollback 进行回滚了,所以数据库中是没有这条记录的:
\n\n在这种事务回滚的情况下,自增值并没有同样发生回滚!如下图所示,自增值仍然固执地从 4 增加到了 5:
\n\n所以这时候我们再去插入一条数据(null, 3, 3)的时候,主键 id 就会被自动赋为 5
了:
那么,为什么在出现唯一键冲突或者回滚的时候,MySQL 没有把表的自增值改回去呢?回退回去的话不就不会发生自增 id 不连续了吗?
\n事实上,这么做的主要原因是为了提高性能。
\n我们直接用反证法来验证:假设 MySQL 在事务回滚的时候会把自增值改回去,会发生什么?
\n现在有两个并行执行的事务 A 和 B,在申请自增值的时候,为了避免两个事务申请到相同的自增 id,肯定要加锁,然后顺序申请,对吧。
\n而为了解决这个主键冲突,有两种方法:
\n很显然,上述两个方法的成本都比较高,会导致性能问题。而究其原因呢,是我们假设的这个 “允许自增 id 回退”。
\n因此,InnoDB 放弃了这个设计,语句执行失败也不回退自增 id。也正是因为这样,所以才只保证了自增 id 是递增的,但不保证是连续的。
\n综上,已经分析了三种自增值不连续的场景,还有第四种场景:批量插入数据。
\n对于批量插入数据的语句,MySQL 有一个批量申请自增 id 的策略:
\n注意,这里说的批量插入数据,不是在普通的 insert 语句里面包含多个 value 值!!!,因为这类语句在申请自增 id 的时候,是可以精确计算出需要多少个 id 的,然后一次性申请,申请完成后锁就可以释放了。
\n而对于 insert … select
、replace …… select 和 load data 这种类型的语句来说,MySQL 并不知道到底需要申请多少 id,所以就采用了这种批量申请的策略,毕竟一个一个申请的话实在太慢了。
举个例子,假设我们现在这个表有下面这些数据:
\n\n我们创建一个和当前表 test_pk
有相同结构定义的表 test_pk2
:
然后使用 insert...select
往 teset_pk2
表中批量插入数据:
可以看到,成功导入了数据。
\n再来看下 test_pk2
的自增值是多少:
如上分析,是 8 而不是 6
\n具体来说,insert……select 实际上往表中插入了 5 行数据 (1 1)(2 2)(3 3)(4 4)(5 5)。但是,这五行数据是分三次申请的自增 id,结合批量申请策略,每次申请到的自增 id 个数都是上一次的两倍,所以:
\n由于这条语句实际只用上了 5 个 id,所以 id=6 和 id=7 就被浪费掉了。之后,再执行 insert into test_pk2 values(null,6,6)
,实际上插入的数据就是(8,6,6):
本文总结下自增值不连续的 4 个场景:
\ninsert...select
语句)如果将悲观锁(Pessimistic Lock)和乐观锁(PessimisticLock 或 OptimisticLock)对应到现实生活中来。悲观锁有点像是一位比较悲观(也可以说是未雨绸缪)的人,总是会假设最坏的情况,避免出现问题。乐观锁有点像是一位比较乐观的人,总是会假设最好的情况,在要出现问题之前快速解决问题。
\n在程序世界中,乐观锁和悲观锁的最终目的都是为了保证线程安全,避免在并发场景下的资源竞争问题。但是,相比于乐观锁,悲观锁对性能的影响更大!
\n悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。
\n像 Java 中synchronized
和ReentrantLock
等独占锁就是悲观锁思想的实现。
public void performSynchronisedTask() {\n synchronized (this) {\n // 需要同步的操作\n }\n}\n\nprivate Lock lock = new ReentrantLock();\nlock.lock();\ntry {\n // 需要同步的操作\n} finally {\n lock.unlock();\n}\n
高并发的场景下,激烈的锁竞争会造成线程阻塞,大量阻塞线程会导致系统的上下文切换,增加系统的性能开销。并且,悲观锁还可能会存在死锁问题,影响代码的正常运行。
\n乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源(也就是数据)是否被其它线程修改了(具体方法可以使用版本号机制或 CAS 算法)。
\n像 Java 中java.util.concurrent.atomic
包下面的原子变量类(比如AtomicInteger
、LongAdder
)就是使用了乐观锁的一种实现方式 CAS 实现的。
// LongAdder 在高并发场景下会比 AtomicInteger 和 AtomicLong 的性能更好\n// 代价就是会消耗更多的内存空间(空间换时间)\nLongAdder longAdder = new LongAdder();\n// 自增\nlongAdder.increment();\n// 获取结果\nlongAdder.sum();\n
高并发的场景下,乐观锁相比悲观锁来说,不存在锁竞争造成线程阻塞,也不会有死锁的问题,在性能上往往会更胜一筹。但是,如果冲突频繁发生(写占比非常多的情况),会频繁失败和重试(悲观锁的开销是固定的),这样同样会非常影响性能,导致 CPU 飙升。
\n不过,大量失败重试的问题也是可以解决的,像我们前面提到的 LongAdder
以空间换时间的方式就解决了这个问题。
理论上来说:
\nLongAdder
),也是可以考虑使用乐观锁的,要视实际情况而定。java.util.concurrent.atomic
包下面的原子变量类)。乐观锁一般会使用版本号机制或 CAS 算法实现,CAS 算法相对来说更多一些,这里需要格外注意。
\n一般是在数据表中加上一个数据版本号 version
字段,表示数据被修改的次数。当数据被修改时,version
值会加一。当线程 A 要更新数据值时,在读取数据的同时也会读取 version
值,在提交更新时,若刚才读取到的 version 值为当前数据库中的 version
值相等时才更新,否则重试更新操作,直到更新成功。
举一个简单的例子:假设数据库中帐户信息表中有一个 version 字段,当前值为 1 ;而当前帐户余额字段( balance
)为 $100 。
version
=1 ),并从其帐户余额中扣除 $50( $100-$50 )。version
=1 ),并从其帐户余额中扣除 $20 ( $100-$20 )。version
=1 ),连同帐户扣除后余额( balance
=$50 ),提交至数据库更新,此时由于提交数据版本等于数据库记录当前版本,数据被更新,数据库记录 version
更新为 2 。version
=1 )试图向数据库提交数据( balance
=$80 ),但此时比对数据库记录版本时发现,操作员 B 提交的数据版本号为 1 ,数据库记录当前版本也为 2 ,不满足 “ 提交版本必须等于当前版本才能执行更新 “ 的乐观锁策略,因此,操作员 B 的提交被驳回。这样就避免了操作员 B 用基于 version
=1 的旧数据修改的结果覆盖操作员 A 的操作结果的可能。
CAS 的全称是 Compare And Swap(比较与交换) ,用于实现乐观锁,被广泛应用于各大框架中。CAS 的思想很简单,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。
\nCAS 是一个原子操作,底层依赖于一条 CPU 的原子指令。
\n\n\n原子操作 即最小不可拆分的操作,也就是说操作一旦开始,就不能被打断,直到操作完成。
\n
CAS 涉及到三个操作数:
\n当且仅当 V 的值等于 E 时,CAS 通过原子方式用新值 N 来更新 V 的值。如果不等,说明已经有其它线程更新了 V,则当前线程放弃更新。
\n举一个简单的例子:线程 A 要修改变量 i 的值为 6,i 原值为 1(V = 1,E=1,N=6,假设不存在 ABA 问题)。
\n当多个线程同时使用 CAS 操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。
\nJava 语言并没有直接实现 CAS,CAS 相关的实现是通过 C++ 内联汇编的形式实现的(JNI 调用)。因此, CAS 的具体实现和操作系统以及 CPU 都有关系。
\nsun.misc
包下的Unsafe
类提供了compareAndSwapObject
、compareAndSwapInt
、compareAndSwapLong
方法来实现的对Object
、int
、long
类型的 CAS 操作
/**\n * CAS\n * @param o 包含要修改field的对象\n * @param offset 对象中某field的偏移量\n * @param expected 期望值\n * @param update 更新值\n * @return true | false\n */\npublic final native boolean compareAndSwapObject(Object o, long offset, Object expected, Object update);\n\npublic final native boolean compareAndSwapInt(Object o, long offset, int expected,int update);\n\npublic final native boolean compareAndSwapLong(Object o, long offset, long expected, long update);\n
关于 Unsafe
类的详细介绍可以看这篇文章:Java 魔法类 Unsafe 详解 - JavaGuide - 2022 。
ABA 问题是乐观锁最常见的问题。
\n如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查到它仍然是 A 值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回 A,那 CAS 操作就会误认为它从来没有被修改过。这个问题被称为 CAS 操作的 \"ABA\"问题。
\nABA 问题的解决思路是在变量前面追加上版本号或者时间戳。JDK 1.5 以后的 AtomicStampedReference
类就是用来解决 ABA 问题的,其中的 compareAndSet()
方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
public boolean compareAndSet(V expectedReference,\n V newReference,\n int expectedStamp,\n int newStamp) {\n Pair<V> current = pair;\n return\n expectedReference == current.reference &&\n expectedStamp == current.stamp &&\n ((newReference == current.reference &&\n newStamp == current.stamp) ||\n casPair(current, Pair.of(newReference, newStamp)));\n}\n
CAS 经常会用到自旋操作来进行重试,也就是不成功就一直循环执行直到成功。如果长时间不成功,会给 CPU 带来非常大的执行开销。
\n如果 JVM 能支持处理器提供的 pause 指令那么效率会有一定的提升,pause 指令有两个作用:
\nCAS 只对单个共享变量有效,当操作涉及跨多个共享变量时 CAS 无效。但是从 JDK 1.5 开始,提供了AtomicReference
类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 CAS 操作.所以我们可以使用锁或者利用AtomicReference
类把多个共享变量合并成一个共享变量来操作。
Elasticsearch 相关的面试题为我的知识星球(点击链接即可查看详细介绍以及加入方法)专属内容,已经整理到了《Java 面试指北》中。
\n\n《Java 面试指北》(点击链接即可查看详细介绍)的部分内容展示如下,你可以将其看作是 JavaGuide 的补充完善,两者可以配合使用。
\n\n为了帮助更多同学准备 Java 面试以及学习 Java ,我创建了一个纯粹的Java 面试知识星球。虽然收费只有培训班/训练营的百分之一,但是知识星球里的内容质量更高,提供的服务也更全面,非常适合准备 Java 面试和学习 Java 的同学。
\n欢迎准备 Java 面试以及学习 Java 的同学加入我的 知识星球,干货非常多,学习氛围也很不错!收费虽然是白菜价,但星球里的内容或许比你参加上万的培训班质量还要高。
\n下面是星球提供的部分服务(点击下方图片即可获取知识星球的详细介绍):
\n\n我有自己的原则,不割韭菜,用心做内容,真心希望帮助到你!
\n如果你感兴趣的话,不妨花 3 分钟左右看看星球的详细介绍:JavaGuide 知识星球详细介绍 。
\n这里再送一个 30 元的星球专属优惠券,数量有限(价格即将上调。老用户续费半价 ,微信扫码即可续费)!
\n\n进入星球之后,记得查看 星球使用指南 (一定要看!!!) 和 星球优质主题汇总 。
\n无任何套路,无任何潜在收费项。用心做内容,不割韭菜!
\n不过, 一定要确定需要再进 。并且, 三天之内觉得内容不满意可以全额退款 。
\n\n", "image": "https://oss.javaguide.cn/javamianshizhibei/elasticsearch-questions.png", "date_published": "2023-01-29T03:31:13.000Z", "date_modified": "2023-10-26T22:44:02.000Z", "authors": [], "tags": [ "数据库" ] }, { "title": "MySQL执行计划分析", "url": "https://javaguide.cn/database/mysql/mysql-query-execution-plan.html", "id": "https://javaguide.cn/database/mysql/mysql-query-execution-plan.html", "summary": " 本文来自公号 MySQL 技术,JavaGuide 对其做了补充完善。原文地址:https://mp.weixin.qq.com/s/d5OowNLtXBGEAbT31sSH4g 优化 SQL 的第一步应该是读懂 SQL 的执行计划。本篇文章,我们一起来学习下 MySQL EXPLAIN 执行计划相关知识。 什么是执行计划? 执行计划 是指一条 SQ...", "content_html": "\n\n本文来自公号 MySQL 技术,JavaGuide 对其做了补充完善。原文地址:https://mp.weixin.qq.com/s/d5OowNLtXBGEAbT31sSH4g
\n
优化 SQL 的第一步应该是读懂 SQL 的执行计划。本篇文章,我们一起来学习下 MySQL EXPLAIN
执行计划相关知识。
执行计划 是指一条 SQL 语句在经过 MySQL 查询优化器 的优化会后,具体的执行方式。
\n执行计划通常用于 SQL 性能分析、优化等场景。通过 EXPLAIN
的结果,可以了解到如数据表的查询顺序、数据查询操作的操作类型、哪些索引可以被命中、哪些索引实际会命中、每个数据表有多少行记录被查询等信息。
MySQL 为我们提供了 EXPLAIN
命令,来获取执行计划的相关信息。
需要注意的是,EXPLAIN
语句并不会真的去执行相关的语句,而是通过查询优化器对语句进行分析,找出最优的查询方案,并显示对应的信息。
EXPLAIN
执行计划支持 SELECT
、DELETE
、INSERT
、REPLACE
以及 UPDATE
语句。我们一般多用于分析 SELECT
查询语句,使用起来非常简单,语法如下:
EXPLAIN + SELECT 查询语句;\n
我们简单来看下一条查询语句的执行计划:
\nmysql> explain SELECT * FROM dept_emp WHERE emp_no IN (SELECT emp_no FROM dept_emp GROUP BY emp_no HAVING COUNT(emp_no)>1);\n+
NoSQL(Not Only SQL 的缩写)泛指非关系型的数据库,主要针对的是键值、文档以及图形类型数据存储。并且,NoSQL 数据库天生支持分布式,数据冗余和数据分片等特性,旨在提供可扩展的高可用高性能数据存储解决方案。
\n一个常见的误解是 NoSQL 数据库或非关系型数据库不能很好地存储关系型数据。NoSQL 数据库可以存储关系型数据—它们与关系型数据库的存储方式不同。
\nNoSQL 数据库代表:HBase、Cassandra、MongoDB、Redis。
\n\n| | SQL 数据库 | NoSQL 数据库 |
\n| :
\n\n少部分内容参考了 MongoDB 官方文档的描述,在此说明一下。
\n
MongoDB 是一个基于 分布式文件存储 的开源 NoSQL 数据库系统,由 C++ 编写的。MongoDB 提供了 面向文档 的存储方式,操作起来比较简单和容易,支持“无模式”的数据建模,可以存储比较复杂的数据类型,是一款非常流行的 文档类型数据库 。
\n在高负载的情况下,MongoDB 天然支持水平扩展和高可用,可以很方便地添加更多的节点/实例,以保证服务性能和可用性。在许多场景下,MongoDB 可以用于代替传统的关系型数据库或键/值存储方式,皆在为 Web 应用提供可扩展的高可用高性能数据存储解决方案。
\nMongoDB 的存储结构区别于传统的关系型数据库,主要由如下三个单元组成:
\n也就是说,MongoDB 将数据记录存储为文档 (更具体来说是BSON 文档),这些文档在集合中聚集在一起,数据库中存储一个或多个文档集合。
\nSQL 与 MongoDB 常见术语对比:
\n| SQL | MongoDB |
\n|
和关系型数据库类似,MongoDB 中也有索引。索引的目的主要是用来提高查询效率,如果没有索引的话,MongoDB 必须执行 集合扫描 ,即扫描集合中的每个文档,以选择与查询语句匹配的文档。如果查询存在合适的索引,MongoDB 可以使用该索引来限制它必须检查的文档数量。并且,MongoDB 可以使用索引中的排序返回排序后的结果。
\n虽然索引可以显著缩短查询时间,但是使用索引、维护索引是有代价的。在执行写入操作时,除了要更新文档之外,还必须更新索引,这必然会影响写入的性能。因此,当有大量写操作而读操作少时,或者不考虑读操作的性能时,都不推荐建立索引。
\nMongoDB 支持多种类型的索引,包括单字段索引、复合索引、多键索引、哈希索引、文本索引、 地理位置索引等,每种类型的索引有不同的使用场合。
\n复合索引中字段的顺序非常重要,例如下图中的复合索引由{userid:1, score:-1}
组成,则该复合索引首先按照userid
升序排序;然后再每个userid
的值内,再按照score
降序排序。
在复合索引中,按照何种方式排序,决定了该索引在查询中是否能被应用到。
\n走复合索引的排序:
\ndb.s2.find().sort({\"userid\": 1, \"score\": -1})\ndb.s2.find().sort({\"userid\": -1, \"score\": 1})\n
不走复合索引的排序:
\ndb.s2.find().sort({\"userid\": 1, \"score\": 1})\ndb.s2.find().sort({\"userid\": -1, \"score\": -1})\ndb.s2.find().sort({\"score\": 1, \"userid\": -1})\ndb.s2.find().sort({\"score\": 1, \"userid\": 1})\ndb.s2.find().sort({\"score\": -1, \"userid\": -1})\ndb.s2.find().sort({\"score\": -1, \"userid\": 1})\n
我们可以通过 explain 进行分析:
\ndb.s2.find().sort({\"score\": -1, \"userid\": 1}).explain()\n
MongoDB 的复合索引遵循左前缀原则:拥有多个键的索引,可以同时得到所有这些键的前缀组成的索引,但不包括除左前缀之外的其他子集。比如说,有一个类似 {a: 1, b: 1, c: 1, ..., z: 1}
这样的索引,那么实际上也等于有了 {a: 1}
、{a: 1, b: 1}
、{a: 1, b: 1, c: 1}
等一系列索引,但是不会有 {b: 1}
这样的非左前缀的索引。
TTL 索引提供了一个过期机制,允许为每一个文档设置一个过期时间 expireAfterSeconds
,当一个文档达到预设的过期时间之后就会被删除。TTL 索引除了有 expireAfterSeconds
属性外,和普通索引一样。
数据过期对于某些类型的信息很有用,比如机器生成的事件数据、日志和会话信息,这些信息只需要在数据库中保存有限的时间。
\nTTL 索引运行原理:
\nTTL 索引限制:
\n_id
字段不支持 TTL 索引。根据官方文档介绍,覆盖查询是以下的查询:
\nnull
。由于所有出现在查询中的字段是索引的一部分, MongoDB 无需在整个数据文档中检索匹配查询条件和返回使用相同索引的查询结果。因为索引存在于内存中,从索引中获取数据比通过扫描文档读取数据要快得多。
\n举个例子:我们有如下 users
集合:
{\n \"_id\": ObjectId(\"53402597d852426020000002\"),\n \"contact\": \"987654321\",\n \"dob\": \"01-01-1991\",\n \"gender\": \"M\",\n \"name\": \"Tom Benzamin\",\n \"user_name\": \"tombenzamin\"\n}\n
我们在 users
集合中创建联合索引,字段为 gender
和 user_name
:
db.users.ensureIndex({gender:1,user_name:1})\n
现在,该索引会覆盖以下查询:
\ndb.users.find({gender:\"M\"},{user_name:1,_id:0})\n
为了让指定的索引覆盖查询,必须显式地指定 _id: 0
来从结果中排除 _id
字段,因为索引不包括 _id
字段。
MongoDB 的复制集群又称为副本集群,是一组维护相同数据集合的 mongod 进程。
\n客户端连接到整个 Mongodb 复制集群,主节点机负责整个复制集群的写,从节点可以进行读操作,但默认还是主节点负责整个复制集群的读。主节点发生故障时,自动从从节点中选举出一个新的主节点,确保集群的正常使用,这对于客户端来说是无感知的。
\n通常来说,一个复制集群包含 1 个主节点(Primary),多个从节点(Secondary)以及零个或 1 个仲裁节点(Arbiter)。
\n下图是一个典型的三成员副本集群:
\n\n主节点与备节点之间是通过 oplog(操作日志) 来同步数据的。oplog 是 local 库下的一个特殊的 上限集合(Capped Collection) ,用来保存写操作所产生的增量日志,类似于 MySQL 中 的 Binlog。
\n\n\n\n上限集合类似于定长的循环队列,数据顺序追加到集合的尾部,当集合空间达到上限时,它会覆盖集合中最旧的文档。上限集合的数据将会被顺序写入到磁盘的固定空间内,所以,I/O 速度非常快,如果不建立索引,性能更好。
\n
当主节点上的一个写操作完成后,会向 oplog 集合写入一条对应的日志,而从节点则通过这个 oplog 不断拉取到新的日志,在本地进行回放以达到数据同步的目的。
\n副本集最多有一个主节点。 如果当前主节点不可用,一个选举会抉择出新的主节点。MongoDB 的节点选举规则能够保证在 Primary 挂掉之后选取的新节点一定是集群中数据最全的一个。
\n分片集群是 MongoDB 的分布式版本,相较副本集,分片集群数据被均衡的分布在不同分片中, 不仅大幅提升了整个集群的数据容量上限,也将读写的压力分散到不同分片,以解决副本集性能瓶颈的难题。
\nMongoDB 的分片集群由如下三个部分组成(下图来源于官方文档对分片集群的介绍):
\n\n随着系统数据量以及吞吐量的增长,常见的解决办法有两种:垂直扩展和水平扩展。
\n垂直扩展通过增加单个服务器的能力来实现,比如磁盘空间、内存容量、CPU 数量等;水平扩展则通过将数据存储到多个服务器上来实现,根据需要添加额外的服务器以增加容量。
\n类似于 Redis Cluster,MongoDB 也可以通过分片实现 水平扩展 。水平扩展这种方式更灵活,可以满足更大数据量的存储需求,支持更高吞吐量。并且,水平扩展所需的整体成本更低,仅仅需要相对较低配置的单机服务器即可,代价是增加了部署的基础设施和维护的复杂性。
\n也就是说当你遇到如下问题时,可以使用分片集群解决:
\n分片键(Shard Key) 是数据分区的前提, 从而实现数据分发到不同服务器上,减轻服务器的负担。也就是说,分片键决定了集合内的文档如何在集群的多个分片间的分布状况。
\n分片键就是文档里面的一个字段,但是这个字段不是普通的字段,有一定的要求:
\n_id
字段,否则您可以更新文档的分片键值。MongoDB 5.0 版本开始,实现了实时重新分片(live resharding),可以实现分片键的完全重新选择。选择合适的片键对 sharding 效率影响很大,主要基于如下四个因素(摘自分片集群使用注意事项 - - 腾讯云文档):
\n综上,在选择片键时要考虑以上 4 个条件,尽可能满足更多的条件,才能降低 MoveChunks 对性能的影响,从而获得最优的性能体验。
\nMongoDB 支持两种分片算法来满足不同的查询需求(摘自MongoDB 分片集群介绍 - 阿里云文档):
\n1、基于范围的分片:
\n\nMongoDB 按照分片键(Shard Key)的值的范围将数据拆分为不同的块(Chunk),每个块包含了一段范围内的数据。当分片键的基数大、频率低且值非单调变更时,范围分片更高效。
\n2、基于 Hash 值的分片
\n\nMongoDB 计算单个字段的哈希值作为索引值,并以哈希值的范围将数据拆分为不同的块(Chunk)。
\n除了上述两种分片策略,您还可以配置 复合片键 ,例如由一个低基数的键和一个单调递增的键组成。
\nChunk(块) 是 MongoDB 分片集群的一个核心概念,其本质上就是由一组 Document 组成的逻辑数据单元。每个 Chunk 包含一定范围片键的数据,互不相交且并集为全部数据,即离散数学中划分的概念。
\n分片集群不会记录每条数据在哪个分片上,而是记录 Chunk 在哪个分片上一级这个 Chunk 包含哪些数据。
\n默认情况下,一个 Chunk 的最大值默认为 64MB(可调整,取值范围为 1~1024 MB。如无特殊需求,建议保持默认值),进行数据插入、更新、删除时,如果此时 Mongos 感知到了目标 Chunk 的大小或者其中的数据量超过上限,则会触发 Chunk 分裂。
\n\n数据的增长会让 Chunk 分裂得越来越多。这个时候,各个分片上的 Chunk 数量可能会不平衡。Mongos 中的 均衡器(Balancer) 组件就会执行自动平衡,尝试使各个 Shard 上 Chunk 的数量保持均衡,这个过程就是 再平衡(Rebalance)。默认情况下,数据库和集合的 Rebalance 是开启的。
\n如下图所示,随着数据插入,导致 Chunk 分裂,让 AB 两个分片有 3 个 Chunk,C 分片只有一个,这个时候就会把 B 分配的迁移一个到 C 分片实现集群数据均衡。
\n\n\n\nBalancer 是 MongoDB 的一个运行在 Config Server 的 Primary 节点上(自 MongoDB 3.4 版本起)的后台进程,它监控每个分片上 Chunk 数量,并在某个分片上 Chunk 数量达到阈值进行迁移。
\n
Chunk 只会分裂,不会合并,即使 chunkSize 的值变大。
\nRebalance 操作是比较耗费系统资源的,我们可以通过在业务低峰期执行、预分片或者设置 Rebalance 时间窗等方式来减少其对 MongoDB 正常使用所带来的影响。
\n关于 Chunk 迁移原理的详细介绍,推荐阅读 MongoDB 中文社区的一文读懂 MongoDB chunk 迁移这篇文章。
\n大部分软件开发从业者,都会忽略软件开发中的一些最基础、最底层的一些概念。但是,这些软件开发的概念对于软件开发来说非常重要,就像是软件开发的基石一样。这也是我写这篇文章的原因。
\n1968 年 NATO(北大西洋公约组织)提出了软件危机(Software crisis)一词。同年,为了解决软件危机问题,“软件工程”的概念诞生了。一门叫做软件工程的学科也就应运而生。
\n随着时间的推移,软件工程这门学科也经历了一轮又一轮的完善,其中的一些核心内容比如软件开发模型越来越丰富实用!
\n什么是软件危机呢?
\n简单来说,软件危机描述了当时软件开发的一个痛点:我们很难高效地开发出质量高的软件。
\nDijkstra(Dijkstra 算法的作者) 在 1972 年图灵奖获奖感言中也提高过软件危机,他是这样说的:“导致软件危机的主要原因是机器变得功能强大了几个数量级!坦率地说:只要没有机器,编程就完全没有问题。当我们有一些弱小的计算机时,编程成为一个温和的问题,而现在我们有了庞大的计算机,编程也同样成为一个巨大的问题”。
\n说了这么多,到底什么是软件工程呢?
\n工程是为了解决实际的问题将理论应用于实践。软件工程指的就是将工程思想应用于软件开发。
\n上面是我对软件工程的定义,我们再来看看比较权威的定义。IEEE 软件工程汇刊给出的定义是这样的: (1)将系统化的、规范的、可量化的方法应用到软件的开发、运行及维护中,即将工程化方法应用于软件。 (2)在(1)中所述方法的研究。
\n总之,软件工程的终极目标就是:在更少资源消耗的情况下,创造出更好、更容易维护的软件。
\n\n\n软件开发过程(英语:software development process),或软件过程(英语:software process),是软件开发的开发生命周期(software development life cycle),其各个阶段实现了软件的需求定义与分析、设计、实现、测试、交付和维护。软件过程是在开发与构建系统时应遵循的步骤,是软件开发的路线图。
\n
软件开发过程只是比较笼统的层面上,一定义了一个软件开发可能涉及到的一些流程。
\n软件开发模型更具体地定义了软件开发过程,对开发过程提供了强有力的理论支持。
\n软件开发模型有很多种,比如瀑布模型(Waterfall Model)、快速原型模型(Rapid Prototype Model)、V 模型(V-model)、W 模型(W-model)、敏捷开发模型。其中最具有代表性的还是 瀑布模型 和 敏捷开发 。
\n瀑布模型 定义了一套完成的软件开发周期,完整地展示了一个软件的的生命周期。
\n\n敏捷开发模型 是目前使用的最多的一种软件开发模型。MBA 智库百科对敏捷开发的描述是这样的:
\n\n\n敏捷开发 是一种以人为核心、迭代、循序渐进的开发方法。在敏捷开发中,软件项目的构建被切分成多个子项目,各个子项目的成果都经过测试,具备集成和可运行的特征。换言之,就是把一个大项目分为多个相互联系,但也可独立运行的小项目,并分别完成,在此过程中软件一直处于可使用状态。
\n
像现在比较常见的一些概念比如 持续集成、重构、小版本发布、低文档、站会、结对编程、测试驱动开发 都是敏捷开发的核心。
\n我们在构建一个新的软件的时候,不需要从零开始,通过复用已有的一些轮子(框架、第三方库等)、设计模式、设计原则等等现成的物料,我们可以更快地构建出一个满足要求的软件。
\n像我们平时接触的开源项目就是最好的例子。我想,如果不是开源,我们构建出一个满足要求的软件,耗费的精力和时间要比现在多的多!
\n构建软件的过程中,我们会遇到很多问题。我们可以将一些比较复杂的问题拆解为一些小问题,然后,一一攻克。
\n我结合现在比较火的软件设计方法—领域驱动设计(Domain Driven Design,简称 DDD)来说说。
\n在领域驱动设计中,很重要的一个概念就是领域(Domain),它就是我们要解决的问题。在领域驱动设计中,我们要做的就是把比较大的领域(问题)拆解为若干的小领域(子域)。
\n除此之外,分而治之也是一个比较常用的算法思想,对应的就是分治算法。如果你想了解分治算法的话,推荐你看一下北大的《算法设计与分析 Design and Analysis of Algorithms》。
\n软件开发是一个逐步演进的过程,我们需要不断进行迭代式增量开发,最终交付符合客户价值的产品。
\n这里补充一个在软件开发领域,非常重要的概念:MVP(Minimum Viable Product,最小可行产品)。
\n这个最小可行产品,可以理解为刚好能够满足客户需求的产品。下面这张图片把这个思想展示的非常精髓。
\n\n利用最小可行产品,我们可以也可以提早进行市场分析,这对于我们在探索产品不确定性的道路上非常有帮助。可以非常有效地指导我们下一步该往哪里走。
\n软件开发是一个不断优化改进的过程。任何软件都有很多可以优化的点,不可能完美。我们需要不断改进和提升软件的质量。
\n但是,也不要陷入这个怪圈。要学会折中,在有限的投入内,以最有效的方式提高现有软件的质量。
\n\n\n这部分内容主要根据 Maven 官方文档整理,做了对应的删减,主要保留比较重要的部分,不涉及实战,主要是一些重要概念的介绍。
\n
Maven 官方文档是这样介绍的 Maven 的:
\n\n\nApache Maven is a software project management and comprehension tool. Based on the concept of a project object model (POM), Maven can manage a project's build, reporting and documentation from a central piece of information.
\nApache Maven 的本质是一个软件项目管理和理解工具。基于项目对象模型 (Project Object Model,POM) 的概念,Maven 可以从一条中心信息管理项目的构建、报告和文档。
\n
什么是 POM? 每一个 Maven 工程都有一个 pom.xml
文件,位于根目录中,包含项目构建生命周期的详细信息。通过 pom.xml
文件,我们可以定义项目的坐标、项目依赖、项目信息、插件信息等等配置。
对于开发者来说,Maven 的主要作用主要有 3 个:
\n关于 Maven 的基本使用这里就不介绍了,建议看看官网的 5 分钟上手 Maven 的教程:Maven in 5 Minutes 。
\n项目中依赖的第三方库以及插件可统称为构件。每一个构件都可以使用 Maven 坐标唯一标识,坐标元素包括:
\n只要你提供正确的坐标,就能从 Maven 仓库中找到相应的构件供我们使用。
\n举个例子(引入阿里巴巴开源的 EasyExcel):
\n<dependency>\n <groupId>com.alibaba</groupId>\n <artifactId>easyexcel</artifactId>\n <version>3.1.1</version>\n</dependency>\n
你可以在 https://mvnrepository.com/ 这个网站上找到几乎所有可用的构件,如果你的项目使用的是 Maven 作为构建工具,那这个网站你一定会经常接触。
\n\n如果使用 Maven 构建产生的构件(例如 Jar 文件)被其他的项目引用,那么该构件就是其他项目的依赖。
\n配置信息示例:
\n<project>\n <dependencies>\n <dependency>\n <groupId></groupId>\n <artifactId></artifactId>\n <version></version>\n <type>...</type>\n <scope>...</scope>\n <optional>...</optional>\n <exclusions>\n <exclusion>\n <groupId>...</groupId>\n <artifactId>...</artifactId>\n </exclusion>\n </exclusions>\n </dependency>\n </dependencies>\n</project>\n
配置说明:
\nclasspath 用于指定 .class
文件存放的位置,类加载器会从该路径中加载所需的 .class
文件到内存中。
Maven 在编译、执行测试、实际运行有着三套不同的 classpath:
\nMaven 的依赖范围如下:
\nservlet-api.jar
在 Tomcat 中已经提供了,我们只需要的是编译期提供而已。1、对于 Maven 而言,同一个 groupId 同一个 artifactId 下,只能使用一个 version。
\n<dependency>\n <groupId>in.hocg.boot</groupId>\n <artifactId>mybatis-plus-spring-boot-starter</artifactId>\n <version>1.0.48</version>\n</dependency>\n<!-- 只会使用 1.0.49 这个版本的依赖 -->\n<dependency>\n <groupId>in.hocg.boot</groupId>\n <artifactId>mybatis-plus-spring-boot-starter</artifactId>\n <version>1.0.49</version>\n</dependency>\n
若相同类型但版本不同的依赖存在于同一个 pom 文件,只会引入后一个声明的依赖。
\n2、项目的两个依赖同时引入了某个依赖。
\n举个例子,项目存在下面这样的依赖关系:
\n依赖链路一:A -> B -> C -> X(1.0)\n依赖链路二:A -> D -> X(2.0)\n
这两条依赖路径上有两个版本的 X,为了避免依赖重复,Maven 只会选择其中的一个进行解析。
\n哪个版本的 X 会被 Maven 解析使用呢?
\nMaven 在遇到这种问题的时候,会遵循 路径最短优先 和 声明顺序优先 两大原则。解决这个问题的过程也被称为 Maven 依赖调解 。
\n路径最短优先
\n依赖链路一:A -> B -> C -> X(1.0) // dist = 3\n依赖链路二:A -> D -> X(2.0) // dist = 2\n
依赖链路二的路径最短,因此,X(2.0)会被解析使用。
\n不过,你也可以发现。路径最短优先原则并不是通用的,像下面这种路径长度相等的情况就不能单单通过其解决了:
\n依赖链路一:A -> B -> X(1.0) // dist = 2\n依赖链路二:A -> D -> X(2.0) // dist = 2\n
因此,Maven 又定义了声明顺序优先原则。
\n依赖调解第一原则不能解决所有问题,比如这样的依赖关系:A->B->Y(1.0)、A-> C->Y(2.0),Y(1.0)和 Y(2.0)的依赖路径长度是一样的,都为 2。Maven 定义了依赖调解的第二原则:
\n声明顺序优先
\n在依赖路径长度相等的前提下,在 pom.xml
中依赖声明的顺序决定了谁会被解析使用,顺序最前的那个依赖优胜。该例中,如果 B 的依赖声明在 D 之前,那么 X (1.0)就会被解析使用。
<!-- A pom.xml -->\n<dependencies>\n ...\n dependency B\n ...\n dependency D\n</dependencies>\n
单纯依赖 Maven 来进行依赖调解,在很多情况下是不适用的,需要我们手动排除依赖。
\n举个例子,当前项目存在下面这样的依赖关系:
\n依赖链路一:A -> B -> C -> X(1.5) // dist = 3\n依赖链路二:A -> D -> X(1.0) // dist = 2\n
根据路径最短优先原则,X(1.0) 会被解析使用,也就是说实际用的是 1.0 版本的 X。
\n但是!!!这会一些问题:如果 D 依赖用到了 1.5 版本的 X 中才有的一个类,运行项目就会报NoClassDefFoundError
错误。如果 D 依赖用到了 1.5 版本的 X 中才有的一个方法,运行项目就会报NoSuchMethodError
错误。
现在知道为什么你的 Maven 项目总是会报NoClassDefFoundError
和NoSuchMethodError
错误了吧?
如何解决呢? 我们可以通过exclusion
标签手动将 X(1.0) 给排除。
<dependency>\n ......\n <exclusions>\n <exclusion>\n <artifactId>x</artifactId>\n <groupId>org.apache.x</groupId>\n </exclusion>\n </exclusions>\n</dependency>\n
一般我们在解决依赖冲突的时候,都会优先保留版本较高的。这是因为大部分 jar 在升级的时候都会做到向下兼容。
\n如果高版本修改了低版本的一些类或者方法的话,这个时候就能直接保留高版本了,而是应该考虑优化上层依赖,比如升级上层依赖的版本。
\n还是上面的例子:
\n依赖链路一:A -> B -> C -> X(1.5) // dist = 3\n依赖链路二:A -> D -> X(1.0) // dist = 2\n
我们保留了 1.5 版本的 X,但是这个版本的 X 删除了 1.0 版本中的某些类。这个时候,我们可以考虑升级 D 的版本到一个 X 兼容的版本。
\n在 Maven 世界中,任何一个依赖、插件或者项目构建的输出,都可以称为 构件 。
\n坐标和依赖是构件在 Maven 世界中的逻辑表示方式,构件的物理表示方式是文件,Maven 通过仓库来统一管理这些文件。 任何一个构件都有一组坐标唯一标识。有了仓库之后,无需手动引入构件,我们直接给定构件的坐标即可在 Maven 仓库中找到该构件。
\nMaven 仓库分为:
\nsettings.xml
文件中可以看到 Maven 的本地仓库路径配置,默认本地仓库路径是在 ${user.home}/.m2/repository
。Maven 远程仓库可以分为:
\nMaven 依赖包寻找顺序:
\nMaven 的生命周期就是为了对所有的构建过程进行抽象和统一,包含了项目的清理、初始化、编译、测试、打包、集成测试、验证、部署和站点生成等几乎所有构建步骤。
\nMaven 定义了 3 个生命周期META-INF/plexus/components.xml
:
default
生命周期clean
生命周期site
生命周期这些生命周期是相互独立的,每个生命周期包含多个阶段(phase)。并且,这些阶段是有序的,也就是说,后面的阶段依赖于前面的阶段。当执行某个阶段的时候,会先执行它前面的阶段。
\n执行 Maven 生命周期的命令格式如下:
\nmvn 阶段 [阶段2] ...[阶段n]\n
default
生命周期是在没有任何关联插件的情况下定义的,是 Maven 的主要生命周期,用于构建应用程序,共包含 23 个阶段。
<phases>\n <!-- 验证项目是否正确,并且所有必要的信息可用于完成构建过程 -->\n <phase>validate</phase>\n <!-- 建立初始化状态,例如设置属性 -->\n <phase>initialize</phase>\n <!-- 生成要包含在编译阶段的源代码 -->\n <phase>generate-sources</phase>\n <!-- 处理源代码 -->\n <phase>process-sources</phase>\n <!-- 生成要包含在包中的资源 -->\n <phase>generate-resources</phase>\n <!-- 将资源复制并处理到目标目录中,为打包阶段做好准备。 -->\n <phase>process-resources</phase>\n <!-- 编译项目的源代码 -->\n <phase>compile</phase>\n <!-- 对编译生成的文件进行后处理,例如对 Java 类进行字节码增强/优化 -->\n <phase>process-classes</phase>\n <!-- 生成要包含在编译阶段的任何测试源代码 -->\n <phase>generate-test-sources</phase>\n <!-- 处理测试源代码 -->\n <phase>process-test-sources</phase>\n <!-- 生成要包含在编译阶段的测试源代码 -->\n <phase>generate-test-resources</phase>\n <!-- 处理从测试代码文件编译生成的文件 -->\n <phase>process-test-resources</phase>\n <!-- 编译测试源代码 -->\n <phase>test-compile</phase>\n <!-- 处理从测试代码文件编译生成的文件 -->\n <phase>process-test-classes</phase>\n <!-- 使用合适的单元测试框架(Junit 就是其中之一)运行测试 -->\n <phase>test</phase>\n <!-- 在实际打包之前,执行任何的必要的操作为打包做准备 -->\n <phase>prepare-package</phase>\n <!-- 获取已编译的代码并将其打包成可分发的格式,例如 JAR、WAR 或 EAR 文件 -->\n <phase>package</phase>\n <!-- 在执行集成测试之前执行所需的操作。 例如,设置所需的环境 -->\n <phase>pre-integration-test</phase>\n <!-- 处理并在必要时部署软件包到集成测试可以运行的环境 -->\n <phase>integration-test</phase>\n <!-- 执行集成测试后执行所需的操作。 例如,清理环境 -->\n <phase>post-integration-test</phase>\n <!-- 运行任何检查以验证打的包是否有效并符合质量标准。 -->\n <phase>verify</phase>\n <!-- \t将包安装到本地仓库中,可以作为本地其他项目的依赖 -->\n <phase>install</phase>\n <!-- 将最终的项目包复制到远程仓库中与其他开发者和项目共享 -->\n <phase>deploy</phase>\n</phases>\n
根据前面提到的阶段间依赖关系理论,当我们执行 mvn test
命令的时候,会执行从 validate 到 test 的所有阶段,这也就解释了为什么执行测试的时候,项目的代码能够自动编译。
clean 生命周期的目的是清理项目,共包含 3 个阶段:
\n<phases>\n <!-- 执行一些需要在clean之前完成的工作 -->\n <phase>pre-clean</phase>\n <!-- 移除所有上一次构建生成的文件 -->\n <phase>clean</phase>\n <!-- 执行一些需要在clean之后立刻完成的工作 -->\n <phase>post-clean</phase>\n</phases>\n<default-phases>\n <clean>\n org.apache.maven.plugins:maven-clean-plugin:2.5:clean\n </clean>\n</default-phases>\n
根据前面提到的阶段间依赖关系理论,当我们执行 mvn clean
的时候,会执行 clean 生命周期中的 pre-clean 和 clean 阶段。
site 生命周期的目的是建立和发布项目站点,共包含 4 个阶段:
\n<phases>\n <!-- 执行一些需要在生成站点文档之前完成的工作 -->\n <phase>pre-site</phase>\n <!-- 生成项目的站点文档作 -->\n <phase>site</phase>\n <!-- 执行一些需要在生成站点文档之后完成的工作,并且为部署做准备 -->\n <phase>post-site</phase>\n <!-- 将生成的站点文档部署到特定的服务器上 -->\n <phase>site-deploy</phase>\n</phases>\n<default-phases>\n <site>\n org.apache.maven.plugins:maven-site-plugin:3.3:site\n </site>\n <site-deploy>\n org.apache.maven.plugins:maven-site-plugin:3.3:deploy\n </site-deploy>\n</default-phases>\n
Maven 能够基于 pom.xml
所包含的信息,自动生成一个友好的站点,方便团队交流和发布项目信息。
Maven 本质上是一个插件执行框架,所有的执行过程,都是由一个一个插件独立完成的。像咱们日常使用到的 install、clean、deploy 等命令,其实底层都是一个一个的 Maven 插件。关于 Maven 的核心插件可以参考官方的这篇文档:https://maven.apache.org/plugins/index.html 。
\n本地默认插件路径: ${user.home}/.m2/repository/org/apache/maven/plugins
除了 Maven 自带的插件之外,还有一些三方提供的插件比如单测覆盖率插件 jacoco-maven-plugin、帮助开发检测代码中不合规范的地方的插件 maven-checkstyle-plugin、分析代码质量的 sonar-maven-plugin。并且,我们还可以自定义插件来满足自己的需求。
\njacoco-maven-plugin 使用示例:
\n<build>\n <plugins>\n <plugin>\n <groupId>org.jacoco</groupId>\n <artifactId>jacoco-maven-plugin</artifactId>\n <version>0.8.8</version>\n <executions>\n <execution>\n <goals>\n <goal>prepare-agent</goal>\n </goals>\n </execution>\n <execution>\n <id>generate-code-coverage-report</id>\n <phase>test</phase>\n <goals>\n <goal>report</goal>\n </goals>\n </execution>\n </executions>\n </plugin>\n </plugins>\n</build>\n
你可以将 Maven 插件理解为一组任务的集合,用户可以通过命令行直接运行指定插件的任务,也可以将插件任务挂载到构建生命周期,随着生命周期运行。
\nMaven 插件被分为下面两种类型:
\n多模块管理简单地来说就是将一个项目分为多个模块,每个模块只负责单一的功能实现。直观的表现就是一个 Maven 项目中不止有一个 pom.xml
文件,会在不同的目录中有多个 pom.xml
文件,进而实现多模块管理。
多模块管理除了可以更加便于项目开发和管理,还有如下好处:
\n多模块管理下,会有一个父模块,其他的都是子模块。父模块通常只有一个 pom.xml
,没有其他内容。父模块的 pom.xml
一般只定义了各个依赖的版本号、包含哪些子模块以及插件有哪些。不过,要注意的是,如果依赖只在某个子项目中使用,则可以在子项目的 pom.xml 中直接引入,防止父 pom 的过于臃肿。
如下图所示,Dubbo 项目就被分成了多个子模块比如 dubbo-common(公共逻辑模块)、dubbo-remoting(远程通讯模块)、dubbo-rpc(远程调用模块)。
\n\n分布式配置中心 相关的面试题为我的知识星球(点击链接即可查看详细介绍以及加入方法)专属内容,已经整理到了《Java 面试指北》中。
\n\n《Java 面试指北》(点击链接即可查看详细介绍)的部分内容展示如下,你可以将其看作是 JavaGuide 的补充完善,两者可以配合使用。
\n\n为了帮助更多同学准备 Java 面试以及学习 Java ,我创建了一个纯粹的Java 面试知识星球。虽然收费只有培训班/训练营的百分之一,但是知识星球里的内容质量更高,提供的服务也更全面,非常适合准备 Java 面试和学习 Java 的同学。
\n欢迎准备 Java 面试以及学习 Java 的同学加入我的 知识星球,干货非常多,学习氛围也很不错!收费虽然是白菜价,但星球里的内容或许比你参加上万的培训班质量还要高。
\n下面是星球提供的部分服务(点击下方图片即可获取知识星球的详细介绍):
\n\n我有自己的原则,不割韭菜,用心做内容,真心希望帮助到你!
\n如果你感兴趣的话,不妨花 3 分钟左右看看星球的详细介绍:JavaGuide 知识星球详细介绍 。
\n这里再送一个 30 元的星球专属优惠券,数量有限(价格即将上调。老用户续费半价 ,微信扫码即可续费)!
\n\n进入星球之后,记得查看 星球使用指南 (一定要看!!!) 和 星球优质主题汇总 。
\n无任何套路,无任何潜在收费项。用心做内容,不割韭菜!
\n不过, 一定要确定需要再进 。并且, 三天之内觉得内容不满意可以全额退款 。
\n\n", "image": "https://oss.javaguide.cn/javamianshizhibei/distributed-system.png", "date_published": "2022-11-03T15:33:32.000Z", "date_modified": "2023-10-26T22:44:02.000Z", "authors": [], "tags": [ "分布式" ] }, { "title": "常见SQL优化手段总结(付费)", "url": "https://javaguide.cn/high-performance/sql-optimization.html", "id": "https://javaguide.cn/high-performance/sql-optimization.html", "summary": "常见 SQL 优化手段总结 相关的内容为我的知识星球(点击链接即可查看详细介绍以及加入方法)专属内容,已经整理到了《Java 面试指北》中。 (点击链接即可查看详细介绍)的部分内容展示如下,你可以将其看作是 JavaGuide 的补充完善,两者可以配合使用。 《Java 面试指北》内容概览《Java 面试指北》内容概览 为了帮助更多同学准备 Java ...", "content_html": "常见 SQL 优化手段总结 相关的内容为我的知识星球(点击链接即可查看详细介绍以及加入方法)专属内容,已经整理到了《Java 面试指北》中。
\n\n《Java 面试指北》(点击链接即可查看详细介绍)的部分内容展示如下,你可以将其看作是 JavaGuide 的补充完善,两者可以配合使用。
\n\n为了帮助更多同学准备 Java 面试以及学习 Java ,我创建了一个纯粹的Java 面试知识星球。虽然收费只有培训班/训练营的百分之一,但是知识星球里的内容质量更高,提供的服务也更全面,非常适合准备 Java 面试和学习 Java 的同学。
\n欢迎准备 Java 面试以及学习 Java 的同学加入我的 知识星球,干货非常多,学习氛围也很不错!收费虽然是白菜价,但星球里的内容或许比你参加上万的培训班质量还要高。
\n下面是星球提供的部分服务(点击下方图片即可获取知识星球的详细介绍):
\n\n我有自己的原则,不割韭菜,用心做内容,真心希望帮助到你!
\n如果你感兴趣的话,不妨花 3 分钟左右看看星球的详细介绍:JavaGuide 知识星球详细介绍 。
\n这里再送一个 30 元的星球专属优惠券,数量有限(价格即将上调。老用户续费半价 ,微信扫码即可续费)!
\n\n进入星球之后,记得查看 星球使用指南 (一定要看!!!) 和 星球优质主题汇总 。
\n无任何套路,无任何潜在收费项。用心做内容,不割韭菜!
\n不过, 一定要确定需要再进 。并且, 三天之内觉得内容不满意可以全额退款 。
\n\n", "image": "https://oss.javaguide.cn/javamianshizhibei/sql-optimization.png", "date_published": "2022-11-03T15:33:32.000Z", "date_modified": "2023-10-26T22:44:02.000Z", "authors": [], "tags": [ "高性能" ] }, { "title": "项目经验指南", "url": "https://javaguide.cn/interview-preparation/project-experience-guide.html", "id": "https://javaguide.cn/interview-preparation/project-experience-guide.html", "summary": " 友情提示 本文节选自 。这是一份教你如何更高效地准备面试的小册,涵盖常见八股文(系统设计、常见框架、分布式、高并发 ……)、优质面经等内容。 没有项目经验怎么办? 没有项目经验是大部分应届生会碰到的一个问题。甚至说,有很多有工作经验的程序员,对自己在公司做的项目不满意,也想找一个比较有技术含量的项目来做。 说几种我觉得比较靠谱的获取项目经验的方式,希...", "content_html": "友情提示
\n本文节选自 《Java 面试指北》。这是一份教你如何更高效地准备面试的小册,涵盖常见八股文(系统设计、常见框架、分布式、高并发 ……)、优质面经等内容。
\n没有项目经验是大部分应届生会碰到的一个问题。甚至说,有很多有工作经验的程序员,对自己在公司做的项目不满意,也想找一个比较有技术含量的项目来做。
\n说几种我觉得比较靠谱的获取项目经验的方式,希望能够对你有启发。
\n在网上找一个符合自己能力与找工作需求的实战项目视频或者专栏,跟着老师一起做。
\n你可以通过慕课网、哔哩哔哩、拉勾、极客时间、培训机构(比如黑马、尚硅谷)等渠道获取到适合自己的实战项目视频/专栏。
\n\n尽量选择一个适合自己的项目,没必要必须做分布式/微服务项目,对于绝大部分同学来说,能把一个单机项目做好就已经很不错了。
\n我面试过很多求职者,简历上看着有微服务的项目经验,结果随便问两个问题就知道根本不是自己做的或者说做的时候压根没认真思考。这种情况会给我留下非常不好的印象。
\n我在 《Java 面试指北》 的「面试准备篇」中也说过:
\n\n\n个人认为也没必要非要去做微服务或者分布式项目,不一定对你面试有利。微服务或者分布式项目涉及的知识点太多,一般人很难吃透。并且,这类项目其实对于校招生来说稍微有一点超标了。即使你做出来,很多面试官也会认为不是你独立完成的。
\n其实,你能把一个单体项目做到极致也很好,对于个人能力提升不比做微服务或者分布式项目差。如何做到极致?代码质量这里就不提了,更重要的是你要尽量让自己的项目有一些亮点(比如你是如何提升项目性能的、如何解决项目中存在的一个痛点的),项目经历取得的成果尽量要量化一下比如我使用 xxx 技术解决了 xxx 问题,系统 qps 从 xxx 提高到了 xxx。
\n
跟着老师做的过程中,你一定要有自己的思考,不要浅尝辄止。对于很多知识点,别人的讲解可能只是满足项目就够了,你自己想多点知识的话,对于重要的知识点就要自己学会去深入学习。
\nGitHub 或者码云上面有很多实战类别项目,你可以选择一个来研究,为了让自己对这个项目更加理解,在理解原有代码的基础上,你可以对原有项目进行改进或者增加功能。
\n你可以参考 Java 优质开源实战项目 上面推荐的实战类开源项目,质量都很高,项目类型也比较全面,涵盖博客/论坛系统、考试/刷题系统、商城系统、权限管理系统、快速开发脚手架以及各种轮子。
\n\n一定要记住:不光要做,还要改进,改善。不论是实战项目视频或者专栏还是实战类开源项目,都一定会有很多可以完善改进的地方。
\n自己动手去做一个自己想完成的东西,遇到不会的东西就临时去学,现学现卖。
\n这个要求比较高,我建议你已经有了一个项目经验之后,再采用这个方法。如果你没有做过项目的话,还是老老实实采用上面两个方法比较好。
\n如果参加这种赛事能获奖的话,项目含金量非常高。即使没获奖也没啥,也可以写简历上。
\n\n通常情况下,你有如下途径接触到企业实际项目的开发:
\n老师接的项目和自己接的私活通常都是一些偏业务的项目,很少会涉及到性能优化。这种情况下,你可以考虑对项目进行改进,别怕花时间,某个时间用心做好一件事情就好比如你对项目的数据模型进行改进、引入缓存提高访问速度等等。
\n实习/工作接触到的项目类似,如果遇到一些偏业务的项目,也是要自己私下对项目进行改进优化。
\n尽量是真的对项目进行了优化,这本身也是对个人能力的提升。如果你实在是没时间去实践的话,也没关系,吃透这个项目优化手段就好,把一些面试可能会遇到的问题提前准备一下。
\n《Java 面试指北》 的「面试准备篇」中有一篇文章专门整理了一些比较高质量的实战项目,非常适合用来学习或者作为项目经验。
\n\n这篇文章一共推荐了 15+ 个实战项目,有业务类的,也有轮子类的,有开源项目、也有视频教程。对于参加校招的小伙伴,我更建议做一个业务类项目加上一个轮子类的项目。
\n很多应届生都是跟着视频做的项目,这个大部分面试官都心知肚明。
\n不排除确实有些面试官不吃这一套,这个也看人。不过我相信大多数面试官都是能理解的,毕竟你在学校的时候实际上是没有什么获得实际项目经验的途径的。
\n大部分应届生的项目经验都是自己在网上找的或者像你一样买的付费课程跟着做的,极少部分是比较真实的项目。 从你能想着做一个实战项目来说,我觉得初衷是好的,确实也能真正学到东西。 但是,究竟有多少是自己掌握了很重要。看视频最忌讳的是被动接受,自己多改进一下,多思考一下!就算是你跟着视频做的项目,也是可以优化的!
\n如果你想真正学到东西的话,建议不光要把项目单纯完成跑起来,还要去自己尝试着优化!
\n简单说几个比较容易的优化点:
\n另外,我在星球分享过常见的性能优化方向实践案例,涉及到多线程、异步、索引、缓存等方向,强烈推荐你看看:https://t.zsxq.com/06EqfeMZZ 。
\n最后,再给大家推荐一个 IDEA 优化代码的小技巧,超级实用!
\n分析你的代码:右键项目-> Analyze->Inspect Code
\n\n扫描完成之后,IDEA 会给出一些可能存在的代码坏味道比如命名问题。
\n\n并且,你还可以自定义检查规则。
\n\n", "image": "https://oss.javaguide.cn/javamianshizhibei/mukewangzhiazhanke.png", "date_published": "2022-11-03T15:33:32.000Z", "date_modified": "2023-10-10T03:03:34.000Z", "authors": [], "tags": [ "面试准备" ] }, { "title": "Java 17 新特性概览(重要)", "url": "https://javaguide.cn/java/new-features/java17.html", "id": "https://javaguide.cn/java/new-features/java17.html", "summary": "Java 17 在 2021 年 9 月 14 日正式发布,是一个长期支持(LTS)版本。 下面这张图是 Oracle 官方给出的 Oracle JDK 支持的时间线。可以看得到,Java 17 最多可以支持到 2029 年 9 月份。 Java 17 将是继 Java 8 以来最重要的长期支持(LTS)版本,是 Java 社区八年努力的成果。Spri...", "content_html": "Java 17 在 2021 年 9 月 14 日正式发布,是一个长期支持(LTS)版本。
\n下面这张图是 Oracle 官方给出的 Oracle JDK 支持的时间线。可以看得到,Java
\n17 最多可以支持到 2029 年 9 月份。
\n\nJava 17 将是继 Java 8 以来最重要的长期支持(LTS)版本,是 Java 社区八年努力的成果。Spring 6.x 和 Spring Boot 3.x 最低支持的就是 Java 17。
\n这次更新共带来 14 个新特性:
\n这里只对 356、398、413、406、407、409、410、411、412、414 这几个我觉得比较重要的新特性进行详细介绍。
\n相关阅读:OpenJDK Java 17 文档 。
\nJDK 17 之前,我们可以借助 Random
、ThreadLocalRandom
和SplittableRandom
来生成随机数。不过,这 3 个类都各有缺陷,且缺少常见的伪随机算法支持。
Java 17 为伪随机数生成器 (pseudorandom number generator,PRNG,又称为确定性随机位生成器)增加了新的接口类型和实现,使得开发者更容易在应用程序中互换使用各种 PRNG 算法。
\n\n\nPRNG 用来生成接近于绝对随机数序列的数字序列。一般来说,PRNG 会依赖于一个初始值,也称为种子,来生成对应的伪随机数序列。只要种子确定了,PRNG 所生成的随机数就是完全确定的,因此其生成的随机数序列并不是真正随机的。
\n
使用示例:
\nRandomGeneratorFactory<RandomGenerator> l128X256MixRandom = RandomGeneratorFactory.of(\"L128X256MixRandom\");\n// 使用时间戳作为随机数种子\nRandomGenerator randomGenerator = l128X256MixRandom.create(System.currentTimeMillis());\n// 生成随机数\nrandomGenerator.nextInt(10);\n
Applet API 用于编写在 Web 浏览器端运行的 Java 小程序,很多年前就已经被淘汰了,已经没有理由使用了。
\nApplet API 在 Java 9 时被标记弃用(JEP 289),但不是为了删除。
\n正如 instanceof
一样, switch
也紧跟着增加了类型匹配自动转换功能。
instanceof
代码示例:
// Old code\nif (o instanceof String) {\n String s = (String)o;\n ... use s ...\n}\n\n// New code\nif (o instanceof String s) {\n ... use s ...\n}\n
switch
代码示例:
// Old code\nstatic String formatter(Object o) {\n String formatted = \"unknown\";\n if (o instanceof Integer i) {\n formatted = String.format(\"int %d\", i);\n } else if (o instanceof Long l) {\n formatted = String.format(\"long %d\", l);\n } else if (o instanceof Double d) {\n formatted = String.format(\"double %f\", d);\n } else if (o instanceof String s) {\n formatted = String.format(\"String %s\", s);\n }\n return formatted;\n}\n\n// New code\nstatic String formatterPatternSwitch(Object o) {\n return switch (o) {\n case Integer i -> String.format(\"int %d\", i);\n case Long l -> String.format(\"long %d\", l);\n case Double d -> String.format(\"double %f\", d);\n case String s -> String.format(\"String %s\", s);\n default -> o.toString();\n };\n}\n\n
对于 null
值的判断也进行了优化。
// Old code\nstatic void testFooBar(String s) {\n if (s == null) {\n System.out.println(\"oops!\");\n return;\n }\n switch (s) {\n case \"Foo\", \"Bar\" -> System.out.println(\"Great\");\n default -> System.out.println(\"Ok\");\n }\n}\n\n// New code\nstatic void testFooBar(String s) {\n switch (s) {\n case null -> System.out.println(\"Oops\");\n case \"Foo\", \"Bar\" -> System.out.println(\"Great\");\n default -> System.out.println(\"Ok\");\n }\n}\n
删除远程方法调用 (RMI) 激活机制,同时保留 RMI 的其余部分。RMI 激活机制已过时且不再使用。
\n密封类由 JEP 360 提出预览,集成到了 Java 15 中。在 JDK 16 中, 密封类得到了改进(更加严格的引用检查和密封类的继承关系),由 JEP 397 提出了再次预览。
\n在 Java 14 & 15 新特性概览 中,我有详细介绍到密封类,这里就不再做额外的介绍了。
\n在 Java 9 的 JEP 295 ,引入了实验性的提前 (AOT) 编译器,在启动虚拟机之前将 Java 类编译为本机代码。
\nJava 17,删除实验性的提前 (AOT) 和即时 (JIT) 编译器,因为该编译器自推出以来很少使用,维护它所需的工作量很大。保留实验性的 Java 级 JVM 编译器接口 (JVMCI),以便开发人员可以继续使用外部构建的编译器版本进行 JIT 编译。
\n弃用安全管理器以便在将来的版本中删除。
\n安全管理器可追溯到 Java 1.0,多年来,它一直不是保护客户端 Java 代码的主要方法,也很少用于保护服务器端代码。为了推动 Java 向前发展,Java 17 弃用安全管理器,以便与旧版 Applet API ( JEP 398 ) 一起移除。
\nJava 程序可以通过该 API 与 Java 运行时之外的代码和数据进行互操作。通过高效地调用外部函数(即 JVM 之外的代码)和安全地访问外部内存(即不受 JVM 管理的内存),该 API 使 Java 程序能够调用本机库并处理本机数据,而不会像 JNI 那样危险和脆弱。
\n外部函数和内存 API 在 Java 17 中进行了第一轮孵化,由 JEP 412 提出。第二轮孵化由JEP 419 提出并集成到了 Java 18 中,预览由 JEP 424 提出并集成到了 Java 19 中。
\n在 Java 19 新特性概览 中,我有详细介绍到外部函数和内存 API,这里就不再做额外的介绍了。
\n向量(Vector) API 最初由 JEP 338 提出,并作为孵化 API集成到 Java 16 中。第二轮孵化由 JEP 414 提出并集成到 Java 17 中,第三轮孵化由 JEP 417 提出并集成到 Java 18 中,第四轮由 JEP 426 提出并集成到了 Java 19 中。
\n该孵化器 API 提供了一个 API 的初始迭代以表达一些向量计算,这些计算在运行时可靠地编译为支持的 CPU 架构上的最佳向量硬件指令,从而获得优于同等标量计算的性能,充分利用单指令多数据(SIMD)技术(大多数现代 CPU 上都可以使用的一种指令)。尽管 HotSpot 支持自动向量化,但是可转换的标量操作集有限且易受代码更改的影响。该 API 将使开发人员能够轻松地用 Java 编写可移植的高性能向量算法。
\n在 Java 18 新特性概览 中,我有详细介绍到向量 API,这里就不再做额外的介绍了。
\n\n", "image": "https://oss.javaguide.cn/github/javaguide/java/new-features/4c1611fad59449edbbd6e233690e9fa7.png", "date_published": "2022-09-28T12:35:46.000Z", "date_modified": "2023-10-26T22:44:02.000Z", "authors": [], "tags": [ "Java" ] }, { "title": "Java 18 新特性概览", "url": "https://javaguide.cn/java/new-features/java18.html", "id": "https://javaguide.cn/java/new-features/java18.html", "summary": "Java 18 在 2022 年 3 月 22 日正式发布,非长期支持版本。 Java 18 带来了 9 个新特性: JEP 400:UTF-8 by Default(默认字符集为 UTF-8) JEP 408:Simple Web Server(简易的 Web 服务器) JEP 413:Code Snippets in Java API Docume...", "content_html": "Java 18 在 2022 年 3 月 22 日正式发布,非长期支持版本。
\nJava 18 带来了 9 个新特性:
\nJava 17 中包含 14 个特性,Java 16 中包含 17 个特性,Java 15 中包含 14 个特性,Java 14 中包含 16 个特性。相比于前面发布的版本来说,Java 18 的新特性少了很多。
\n这里只对 400、408、413、416、417、418、419 这几个我觉得比较重要的新特性进行详细介绍。
\n相关阅读:
\n\nJDK 终于将 UTF-8 设置为默认字符集。
\n在 Java 17 及更早版本中,默认字符集是在 Java 虚拟机运行时才确定的,取决于不同的操作系统、区域设置等因素,因此存在潜在的风险。就比如说你在 Mac 上运行正常的一段打印文字到控制台的 Java 程序到了 Windows 上就会出现乱码,如果你不手动更改字符集的话。
\nJava 18 之后,你可以使用 jwebserver
命令启动一个简易的静态 Web 服务器。
$ jwebserver\nBinding to loopback by default. For all interfaces use \"-b 0.0.0.0\" or \"-b ::\".\nServing /cwd and subdirectories on 127.0.0.1 port 8000\nURL: http://127.0.0.1:8000/\n
这个服务器不支持 CGI 和 Servlet,只限于静态文件。
\n在 Java 18 之前,如果我们想要在 Javadoc 中引入代码片段可以使用 <pre>{@code ...}</pre>
。
<pre>{@code\n lines of source code\n}</pre>\n
<pre>{@code ...}</pre>
这种方式生成的效果比较一般。
在 Java 18 之后,可以通过 @snippet
标签来做这件事情。
/**\n * The following code shows how to use {@code Optional.isPresent}:\n * {@snippet :\n * if (v.isPresent()) {\n * System.out.println(\"v: \" + v.get());\n * }\n * }\n */\n
@snippet
这种方式生成的效果更好且使用起来更方便一些。
Java 18 改进了 java.lang.reflect.Method
、Constructor
的实现逻辑,使之性能更好,速度更快。这项改动不会改动相关 API ,这意味着开发中不需要改动反射相关代码,就可以体验到性能更好反射。
OpenJDK 官方给出了新老实现的反射性能基准测试结果。
\n\n向量(Vector) API 最初由 JEP 338 提出,并作为孵化 API集成到 Java 16 中。第二轮孵化由 JEP 414 提出并集成到 Java 17 中,第三轮孵化由 JEP 417 提出并集成到 Java 18 中,第四轮由 JEP 426 提出并集成到了 Java 19 中。
\n向量计算由对向量的一系列操作组成。向量 API 用来表达向量计算,该计算可以在运行时可靠地编译为支持的 CPU 架构上的最佳向量指令,从而实现优于等效标量计算的性能。
\n向量 API 的目标是为用户提供简洁易用且与平台无关的表达范围广泛的向量计算。
\n这是对数组元素的简单标量计算:
\nvoid scalarComputation(float[] a, float[] b, float[] c) {\n for (int i = 0; i < a.length; i++) {\n c[i] = (a[i] * a[i] + b[i] * b[i]) * -1.0f;\n }\n}\n
这是使用 Vector API 进行的等效向量计算:
\nstatic final VectorSpecies<Float> SPECIES = FloatVector.SPECIES_PREFERRED;\n\nvoid vectorComputation(float[] a, float[] b, float[] c) {\n int i = 0;\n int upperBound = SPECIES.loopBound(a.length);\n for (; i < upperBound; i += SPECIES.length()) {\n // FloatVector va, vb, vc;\n var va = FloatVector.fromArray(SPECIES, a, i);\n var vb = FloatVector.fromArray(SPECIES, b, i);\n var vc = va.mul(va)\n .add(vb.mul(vb))\n .neg();\n vc.intoArray(c, i);\n }\n for (; i < a.length; i++) {\n c[i] = (a[i] * a[i] + b[i] * b[i]) * -1.0f;\n }\n}\n\n
在 JDK 18 中,向量 API 的性能得到了进一步的优化。
\nJava 18 定义了一个全新的 SPI(service-provider interface),用于主要名称和地址的解析,以便 java.net.InetAddress
可以使用平台之外的第三方解析器。
Java 程序可以通过该 API 与 Java 运行时之外的代码和数据进行互操作。通过高效地调用外部函数(即 JVM 之外的代码)和安全地访问外部内存(即不受 JVM 管理的内存),该 API 使 Java 程序能够调用本机库并处理本机数据,而不会像 JNI 那样危险和脆弱。
\n外部函数和内存 API 在 Java 17 中进行了第一轮孵化,由 JEP 412 提出。第二轮孵化由JEP 419 提出并集成到了 Java 18 中,预览由 JEP 424 提出并集成到了 Java 19 中。
\n在 Java 19 新特性概览 中,我有详细介绍到外部函数和内存 API,这里就不再做额外的介绍了。
\n\n", "image": "https://oss.javaguide.cn/github/javaguide/java/new-features/JEP416Benchmark.png", "date_published": "2022-09-13T12:49:20.000Z", "date_modified": "2023-10-26T22:44:02.000Z", "authors": [], "tags": [ "Java" ] }, { "title": "Java 19 新特性概览", "url": "https://javaguide.cn/java/new-features/java19.html", "id": "https://javaguide.cn/java/new-features/java19.html", "summary": "JDK 19 定于 2022 年 9 月 20 日正式发布以供生产使用,非长期支持版本。不过,JDK 19 中有一些比较重要的新特性值得关注。 JDK 19 只有 7 个新特性: JEP 405: Record Patterns(记录模式)(预览) JEP 422: Linux/RISC-V Port JEP 424: Foreign Function...", "content_html": "JDK 19 定于 2022 年 9 月 20 日正式发布以供生产使用,非长期支持版本。不过,JDK 19 中有一些比较重要的新特性值得关注。
\nJDK 19 只有 7 个新特性:
\n这里只对 424、425、426、428 这 4 个我觉得比较重要的新特性进行详细介绍。
\n相关阅读:OpenJDK Java 19 文档
\nJava 程序可以通过该 API 与 Java 运行时之外的代码和数据进行互操作。通过高效地调用外部函数(即 JVM 之外的代码)和安全地访问外部内存(即不受 JVM 管理的内存),该 API 使 Java 程序能够调用本机库并处理本机数据,而不会像 JNI 那样危险和脆弱。
\n外部函数和内存 API 在 Java 17 中进行了第一轮孵化,由 JEP 412 提出。第二轮孵化由JEP 419 提出并集成到了 Java 18 中,预览由 JEP 424 提出并集成到了 Java 19 中。
\n在没有外部函数和内存 API 之前:
\nsun.misc.Unsafe
提供一些执行低级别、不安全操作的方法(如直接访问系统内存资源、自主管理内存资源等),Unsafe
类让 Java 语言拥有了类似 C 语言指针一样操作内存空间的能力的同时,也增加了 Java 语言的不安全性,不正确使用 Unsafe
类会使得程序出错的概率变大。引入外部函数和内存 API 就是为了解决 Java 访问外部函数和外部内存存在的一些痛点。
\nForeign Function & Memory API (FFM API) 定义了类和接口:
\nMemorySegment
、、MemoryAddress
和SegmentAllocator
);MemoryLayout
, VarHandle
;MemorySession
;Linker
、FunctionDescriptor
和SymbolLookup
。下面是 FFM API 使用示例,这段代码获取了 C 库函数的 radixsort
方法句柄,然后使用它对 Java 数组中的四个字符串进行排序。
// 1. 在C库路径上查找外部函数\nLinker linker = Linker.nativeLinker();\nSymbolLookup stdlib = linker.defaultLookup();\nMethodHandle radixSort = linker.downcallHandle(\n stdlib.lookup(\"radixsort\"), ...);\n// 2. 分配堆上内存以存储四个字符串\nString[] javaStrings = { \"mouse\", \"cat\", \"dog\", \"car\" };\n// 3. 分配堆外内存以存储四个指针\nSegmentAllocator allocator = implicitAllocator();\nMemorySegment offHeap = allocator.allocateArray(ValueLayout.ADDRESS, javaStrings.length);\n// 4. 将字符串从堆上复制到堆外\nfor (int i = 0; i < javaStrings.length; i++) {\n // 在堆外分配一个字符串,然后存储指向它的指针\n MemorySegment cString = allocator.allocateUtf8String(javaStrings[i]);\n offHeap.setAtIndex(ValueLayout.ADDRESS, i, cString);\n}\n// 5. 通过调用外部函数对堆外数据进行排序\nradixSort.invoke(offHeap, javaStrings.length, MemoryAddress.NULL, '\\0');\n// 6. 将(重新排序的)字符串从堆外复制到堆上\nfor (int i = 0; i < javaStrings.length; i++) {\n MemoryAddress cStringPtr = offHeap.getAtIndex(ValueLayout.ADDRESS, i);\n javaStrings[i] = cStringPtr.getUtf8String(0);\n}\nassert Arrays.equals(javaStrings, new String[] {\"car\", \"cat\", \"dog\", \"mouse\"}); // true\n
虚拟线程(Virtual Thread-)是 JDK 而不是 OS 实现的轻量级线程(Lightweight Process,LWP),许多虚拟线程共享同一个操作系统线程,虚拟线程的数量可以远大于操作系统线程的数量。
\n虚拟线程在其他多线程语言中已经被证实是十分有用的,比如 Go 中的 Goroutine、Erlang 中的进程。
\n虚拟线程避免了上下文切换的额外耗费,兼顾了多线程的优点,简化了高并发程序的复杂,可以有效减少编写、维护和观察高吞吐量并发应用程序的工作量。
\n知乎有一个关于 Java 19 虚拟线程的讨论,感兴趣的可以去看看:https://www.zhihu.com/question/536743167 。
\nJava 虚拟线程的详细解读和原理可以看下面这两篇文章:
\n\n向量(Vector) API 最初由 JEP 338 提出,并作为孵化 API集成到 Java 16 中。第二轮孵化由 JEP 414 提出并集成到 Java 17 中,第三轮孵化由 JEP 417 提出并集成到 Java 18 中,第四轮由 JEP 426 提出并集成到了 Java 19 中。
\n在 Java 18 新特性概览 中,我有详细介绍到向量 API,这里就不再做额外的介绍了。
\nJDK 19 引入了结构化并发,一种多线程编程方法,目的是为了通过结构化并发 API 来简化多线程编程,并不是为了取代java.util.concurrent
,目前处于孵化器阶段。
结构化并发将不同线程中运行的多个任务视为单个工作单元,从而简化错误处理、提高可靠性并增强可观察性。也就是说,结构化并发保留了单线程代码的可读性、可维护性和可观察性。
\n结构化并发的基本 API 是StructuredTaskScope
。StructuredTaskScope
支持将任务拆分为多个并发子任务,在它们自己的线程中执行,并且子任务必须在主任务继续之前完成。
StructuredTaskScope
的基本用法如下:
try (var scope = new StructuredTaskScope<Object>()) {\n // 使用fork方法派生线程来执行子任务\n Future<Integer> future1 = scope.fork(task1);\n Future<String> future2 = scope.fork(task2);\n // 等待线程完成\n scope.join();\n // 结果的处理可能包括处理或重新抛出异常\n ... process results/exceptions ...\n } // close\n
结构化并发非常适合虚拟线程,虚拟线程是 JDK 实现的轻量级线程。许多虚拟线程共享同一个操作系统线程,从而允许非常多的虚拟线程。
\n\n", "date_published": "2022-09-13T01:11:51.000Z", "date_modified": "2023-12-30T09:14:13.000Z", "authors": [], "tags": [ "Java" ] }, { "title": "ARP 协议详解(网络层)", "url": "https://javaguide.cn/cs-basics/network/arp.html", "id": "https://javaguide.cn/cs-basics/network/arp.html", "summary": "每当我们学习一个新的网络协议的时候,都要把他结合到 OSI 七层模型中,或者是 TCP/IP 协议栈中来学习,一是要学习该协议在整个网络协议栈中的位置,二是要学习该协议解决了什么问题,地位如何?三是要学习该协议的工作原理,以及一些更深入的细节。 ARP 协议,可以说是在协议栈中属于一个偏底层的、非常重要的、又非常简单的通信协议。 开始阅读这篇文章之前,...", "content_html": "每当我们学习一个新的网络协议的时候,都要把他结合到 OSI 七层模型中,或者是 TCP/IP 协议栈中来学习,一是要学习该协议在整个网络协议栈中的位置,二是要学习该协议解决了什么问题,地位如何?三是要学习该协议的工作原理,以及一些更深入的细节。
\nARP 协议,可以说是在协议栈中属于一个偏底层的、非常重要的、又非常简单的通信协议。
\n开始阅读这篇文章之前,你可以先看看下面几个问题:
\n在介绍 ARP 协议之前,有必要介绍一下 MAC 地址。
\nMAC 地址的全称是 媒体访问控制地址(Media Access Control Address)。如果说,互联网中每一个资源都由 IP 地址唯一标识(IP 协议内容),那么一切网络设备都由 MAC 地址唯一标识。
\n\n可以理解为,MAC 地址是一个网络设备真正的身份证号,IP 地址只是一种不重复的定位方式(比如说住在某省某市某街道的张三,这种逻辑定位是 IP 地址,他的身份证号才是他的 MAC 地址),也可以理解为 MAC 地址是身份证号,IP 地址是邮政地址。MAC 地址也有一些别称,如 LAN 地址、物理地址、以太网地址等。
\n\n\n还有一点要知道的是,不仅仅是网络资源才有 IP 地址,网络设备也有 IP 地址,比如路由器。但从结构上说,路由器等网络设备的作用是组成一个网络,而且通常是内网,所以它们使用的 IP 地址通常是内网 IP,内网的设备在与内网以外的设备进行通信时,需要用到 NAT 协议。
\n
MAC 地址的长度为 6 字节(48 比特),地址空间大小有 280 万亿之多(),MAC 地址由 IEEE 统一管理与分配,理论上,一个网络设备中的网卡上的 MAC 地址是永久的。不同的网卡生产商从 IEEE 那里购买自己的 MAC 地址空间(MAC 的前 24 比特),也就是前 24 比特由 IEEE 统一管理,保证不会重复。而后 24 比特,由各家生产商自己管理,同样保证生产的两块网卡的 MAC 地址不会重复。
\nMAC 地址具有可携带性、永久性,身份证号永久地标识一个人的身份,不论他到哪里都不会改变。而 IP 地址不具有这些性质,当一台设备更换了网络,它的 IP 地址也就可能发生改变,也就是它在互联网中的定位发生了变化。
\n最后,记住,MAC 地址有一个特殊地址:FF-FF-FF-FF-FF-FF(全 1 地址),该地址表示广播地址。
\nARP 协议工作时有一个大前提,那就是 ARP 表。
\n在一个局域网内,每个网络设备都自己维护了一个 ARP 表,ARP 表记录了某些其他网络设备的 IP 地址-MAC 地址映射关系,该映射关系以 <IP, MAC, TTL>
三元组的形式存储。其中,TTL 为该映射关系的生存周期,典型值为 20 分钟,超过该时间,该条目将被丢弃。
ARP 的工作原理将分两种场景讨论:
\n假设当前有如下场景:IP 地址为137.196.7.23
的主机 A,想要给同一局域网内的 IP 地址为137.196.7.14
主机 B,发送 IP 数据报文。
\n\n再次强调,当主机发送 IP 数据报文时(网络层),仅知道目的地的 IP 地址,并不清楚目的地的 MAC 地址,而 ARP 协议就是解决这一问题的。
\n
为了达成这一目标,主机 A 将不得不通过 ARP 协议来获取主机 B 的 MAC 地址,并将 IP 报文封装成链路层帧,发送到下一跳上。在该局域网内,关于此将按照时间顺序,依次发生如下事件:
\n主机 A 检索自己的 ARP 表,发现 ARP 表中并无主机 B 的 IP 地址对应的映射条目,也就无从知道主机 B 的 MAC 地址。
\n主机 A 将构造一个 ARP 查询分组,并将其广播到所在的局域网中。
\nARP 分组是一种特殊报文,ARP 分组有两类,一种是查询分组,另一种是响应分组,它们具有相同的格式,均包含了发送和接收的 IP 地址、发送和接收的 MAC 地址。当然了,查询分组中,发送的 IP 地址,即为主机 A 的 IP 地址,接收的 IP 地址即为主机 B 的 IP 地址,发送的 MAC 地址也是主机 A 的 MAC 地址,但接收的 MAC 地址绝不会是主机 B 的 MAC 地址(因为这正是我们要问询的!),而是一个特殊值——FF-FF-FF-FF-FF-FF
,之前说过,该 MAC 地址是广播地址,也就是说,查询分组将广播给该局域网内的所有设备。
主机 A 构造的查询分组将在该局域网内广播,理论上,每一个设备都会收到该分组,并检查查询分组的接收 IP 地址是否为自己的 IP 地址,如果是,说明查询分组已经到达了主机 B,否则,该查询分组对当前设备无效,丢弃之。
\n主机 B 收到了查询分组之后,验证是对自己的问询,接着构造一个 ARP 响应分组,该分组的目的地只有一个——主机 A,发送给主机 A。同时,主机 B 提取查询分组中的 IP 地址和 MAC 地址信息,在自己的 ARP 表中构造一条主机 A 的 IP-MAC 映射记录。
\nARP 响应分组具有和 ARP 查询分组相同的构造,不同的是,发送和接受的 IP 地址恰恰相反,发送的 MAC 地址为发送者本身,目标 MAC 地址为查询分组的发送者,也就是说,ARP 响应分组只有一个目的地,而非广播。
\n主机 A 终将收到主机 B 的响应分组,提取出该分组中的 IP 地址和 MAC 地址后,构造映射信息,加入到自己的 ARP 表中。
\n在整个过程中,有几点需要补充说明的是:
\n总结来说,ARP 协议是一个广播问询,单播响应协议。
\n更复杂的情况是,发送主机 A 和接收主机 B 不在同一个子网中,假设一个一般场景,两台主机所在的子网由一台路由器联通。这里需要注意的是,一般情况下,我们说网络设备都有一个 IP 地址和一个 MAC 地址,这里说的网络设备,更严谨的说法应该是一个接口。路由器作为互联设备,具有多个接口,每个接口同样也应该具备不重复的 IP 地址和 MAC 地址。因此,在讨论 ARP 表时,路由器的多个接口都各自维护一个 ARP 表,而非一个路由器只维护一个 ARP 表。
\n接下来,回顾同一子网内的 MAC 寻址,如果主机 A 发送一个广播问询分组,那么 A 所在的子网内所有设备(接口)都将会捕获该分组,因为该分组的目的 IP 与发送主机 A 的 IP 在同一个子网中。但是当目的 IP 与 A 不在同一子网时,A 所在子网内将不会有设备成功接收该分组。那么,主机 A 应该发送怎样的查询分组呢?整个过程按照时间顺序发生的事件如下:
\n主机 A 查询 ARP 表,期望寻找到目标路由器的本子网接口的 MAC 地址。
\n目标路由器指的是,根据目的主机 B 的 IP 地址,分析出 B 所在的子网,能够把报文转发到 B 所在子网的那个路由器。
\n主机 A 未能找到目标路由器的本子网接口的 MAC 地址,将采用 ARP 协议,问询到该 MAC 地址,由于目标接口与主机 A 在同一个子网内,该过程与同一局域网内的 MAC 寻址相同。
\n主机 A 获取到目标接口的 MAC 地址,先构造 IP 数据报,其中源 IP 是 A 的 IP 地址,目的 IP 地址是 B 的 IP 地址,再构造链路层帧,其中源 MAC 地址是 A 的 MAC 地址,目的 MAC 地址是本子网内与路由器连接的接口的 MAC 地址。主机 A 将把这个链路层帧,以单播的方式,发送给目标接口。
\n目标接口接收到了主机 A 发过来的链路层帧,解析,根据目的 IP 地址,查询转发表,将该 IP 数据报转发到与主机 B 所在子网相连的接口上。
\n到此,该帧已经从主机 A 所在的子网,转移到了主机 B 所在的子网了。
\n路由器接口查询 ARP 表,期望寻找到主机 B 的 MAC 地址。
\n路由器接口如未能找到主机 B 的 MAC 地址,将采用 ARP 协议,广播问询,单播响应,获取到主机 B 的 MAC 地址。
\n路由器接口将对 IP 数据报重新封装成链路层帧,目标 MAC 地址为主机 B 的 MAC 地址,单播发送,直到目的地。
\n这是一则或许对你有用的小广告
\n网上有很多分布式锁相关的文章,写了一个相对简洁易懂的版本,针对面试和工作应该够用了。
\n这篇文章我们先介绍一下分布式锁的基本概念。
\n在多线程环境中,如果多个线程同时访问共享资源(例如商品库存、外卖订单),会发生数据竞争,可能会导致出现脏数据或者系统问题,威胁到程序的正常运行。
\n举个例子,假设现在有 100 个用户参与某个限时秒杀活动,每位用户限购 1 件商品,且商品的数量只有 3 个。如果不对共享资源进行互斥访问,就可能出现以下情况:
\n为了保证共享资源被安全地访问,我们需要使用互斥操作对共享资源进行保护,即同一时刻只允许一个线程访问共享资源,其他线程需要等待当前线程释放后才能访问。这样可以避免数据竞争和脏数据问题,保证程序的正确性和稳定性。
\n如何才能实现共享资源的互斥访问呢? 锁是一个比较通用的解决方案,更准确点来说是悲观锁。
\n悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。
\n对于单机多线程来说,在 Java 中,我们通常使用 ReetrantLock
类、synchronized
关键字这类 JDK 自带的 本地锁 来控制一个 JVM 进程内的多个线程对本地共享资源的访问。
下面是我对本地锁画的一张示意图。
\n\n从图中可以看出,这些线程访问共享资源是互斥的,同一时刻只有一个线程可以获取到本地锁访问共享资源。
\n分布式系统下,不同的服务/客户端通常运行在独立的 JVM 进程上。如果多个 JVM 进程共享同一份资源的话,使用本地锁就没办法实现资源的互斥访问了。于是,分布式锁 就诞生了。
\n举个例子:系统的订单服务一共部署了 3 份,都对外提供服务。用户下订单之前需要检查库存,为了防止超卖,这里需要加锁以实现对检查库存操作的同步访问。由于订单服务位于不同的 JVM 进程中,本地锁在这种情况下就没办法正常工作了。我们需要用到分布式锁,这样的话,即使多个线程不在同一个 JVM 进程中也能获取到同一把锁,进而实现共享资源的互斥访问。
\n下面是我对分布式锁画的一张示意图。
\n\n从图中可以看出,这些独立的进程中的线程访问共享资源是互斥的,同一时刻只有一个线程可以获取到分布式锁访问共享资源。
\n一个最基本的分布式锁需要满足:
\n除了上面这三个基本条件之外,一个好的分布式锁还需要满足下面这些条件:
\n常见分布式锁实现方案如下:
\n关系型数据库的方式一般是通过唯一索引或者排他锁实现。不过,一般不会使用这种方式,问题太多比如性能太差、不具备锁失效机制。
\n基于 ZooKeeper 或者 Redis 实现分布式锁这两种实现方式要用的更多一些,我专门写了一篇文章来详细介绍这两种方案:分布式锁常见实现方案总结。
\n这篇文章我们主要介绍了:
\nCDN 全称是 Content Delivery Network/Content Distribution Network,翻译过的意思是 内容分发网络 。
\n我们可以将内容分发网络拆开来看:
\n所以,简单来说,CDN 就是将静态资源分发到多个不同的地方以实现就近访问,进而加快静态资源的访问速度,减轻服务器以及带宽的负担。
\n类似于京东建立的庞大的仓储运输体系,京东物流在全国拥有非常多的仓库,仓储网络几乎覆盖全国所有区县。这样的话,用户下单的第一时间,商品就从距离用户最近的仓库,直接发往对应的配送站,再由京东小哥送到你家。
\n\n你可以将 CDN 看作是服务上一层的特殊缓存服务,分布在全国各地,主要用来处理静态资源的请求。
\n\n我们经常拿全站加速和内容分发网络做对比,不要把两者搞混了!全站加速(不同云服务商叫法不同,腾讯云叫 ECDN、阿里云叫 DCDN)既可以加速静态资源又可以加速动态资源,内容分发网络(CDN)主要针对的是 静态资源 。
\n\n绝大部分公司都会在项目开发中使用 CDN 服务,但很少会有自建 CDN 服务的公司。基于成本、稳定性和易用性考虑,建议直接选择专业的云厂商(比如阿里云、腾讯云、华为云、青云)或者 CDN 厂商(比如网宿、蓝汛)提供的开箱即用的 CDN 服务。
\n很多朋友可能要问了:既然是就近访问,为什么不直接将服务部署在多个不同的地方呢?
\n同一个服务在在多个不同的地方部署多份(比如同城灾备、异地灾备、同城多活、异地多活)是为了实现系统的高可用而不是就近访问。
\n搞懂下面 3 个问题也就搞懂了 CDN 的工作原理:
\n你可以通过 预热 的方式将源站的资源同步到 CDN 的节点中。这样的话,用户首次请求资源可以直接从 CDN 节点中取,无需回源。这样可以降低源站压力,提升用户体验。
\n如果不预热的话,你访问的资源可能不在 CDN 节点中,这个时候 CDN 节点将请求源站获取资源,这个过程是大家经常说的 回源。
\n\n\n\n\n
\n- 回源:当 CDN 节点上没有用户请求的资源或该资源的缓存已经过期时,CDN 节点需要从原始服务器获取最新的资源内容,这个过程就是回源。当用户请求发生回源的话,会导致该请求的响应速度比未使用 CDN 还慢,因为相比于未使用 CDN 还多了一层 CDN 的调用流程。
\n- 预热:预热是指在 CDN 上提前将内容缓存到 CDN 节点上。这样当用户在请求这些资源时,能够快速地从最近的 CDN 节点获取到而不需要回源,进而减少了对源站的访问压力,提高了访问速度。
\n
如果资源有更新的话,你也可以对其 刷新 ,删除 CDN 节点上缓存的旧资源,并强制 CDN 节点回源站获取最新资源。
\n几乎所有云厂商提供的 CDN 服务都具备缓存的刷新和预热功能(下图是阿里云 CDN 服务提供的相应功能):
\n\n命中率 和 回源率 是衡量 CDN 服务质量两个重要指标。命中率越高越好,回源率越低越好。
\nGSLB (Global Server Load Balance,全局负载均衡)是 CDN 的大脑,负责多个 CDN 节点之间相互协作,最常用的是基于 DNS 的 GSLB。
\nCDN 会通过 GSLB 找到最合适的 CDN 节点,更具体点来说是下面这样的:
\n为了方便理解,上图其实做了一点简化。GSLB 内部可以看作是 CDN 专用 DNS 服务器和负载均衡系统组合。CDN 专用 DNS 服务器会返回负载均衡系统 IP 地址给浏览器,浏览器使用 IP 地址请求负载均衡系统进而找到对应的 CDN 节点。
\nGSLB 是如何选择出最合适的 CDN 节点呢? GSLB 会根据请求的 IP 地址、CDN 节点状态(比如负载情况、性能、响应时间、带宽)等指标来综合判断具体返回哪一个 CDN 节点的地址。
\n如果我们的资源被其他用户或者网站非法盗刷的话,将会是一笔不小的开支。
\n解决这个问题最常用最简单的办法设置 Referer 防盗链,具体来说就是根据 HTTP 请求的头信息里面的 Referer 字段对请求进行限制。我们可以通过 Referer 字段获取到当前请求页面的来源页面的网站地址,这样我们就能确定请求是否来自合法的网站。
\nCDN 服务提供商几乎都提供了这种比较基础的防盗链机制。
\n\n不过,如果站点的防盗链配置允许 Referer 为空的话,通过隐藏 Referer,可以直接绕开防盗链。
\n通常情况下,我们会配合其他机制来确保静态资源被盗用,一种常用的机制是 时间戳防盗链 。相比之下,时间戳防盗链 的安全性更强一些。时间戳防盗链加密的 URL 具有时效性,过期之后就无法再被允许访问。
\n时间戳防盗链的 URL 通常会有两个参数一个是签名字符串,一个是过期时间。签名字符串一般是通过对用户设定的加密字符串、请求路径、过期时间通过 MD5 哈希算法取哈希的方式获得。
\n时间戳防盗链 URL 示例:
\nhttp://cdn.wangsu.com/4/123.mp3? wsSecret=79aead3bd7b5db4adeffb93a010298b5&wsTime=1601026312\n
wsSecret
:签名字符串。wsTime
: 过期时间。时间戳防盗链的实现也比较简单,并且可靠性较高,推荐使用。并且,绝大部分 CDN 服务提供商都提供了开箱即用的时间戳防盗链机制。
\n\n除了 Referer 防盗链和时间戳防盗链之外,你还可以 IP 黑白名单配置、IP 访问限频配置等机制来防盗刷。
\n\n\n原文地址:https://juejin.cn/post/7122014462181113887,JavaGuide 对本文进行了完善总结。
\n
我有一个朋友做了一个小破站,现在要实现一个站内信 Web 消息推送的功能,对,就是下图这个小红点,一个很常用的功能。
\n\n不过他还没想好用什么方式做,这里我帮他整理了一下几种方案,并简单做了实现。
\n推送的场景比较多,比如有人关注我的公众号,这时我就会收到一条推送消息,以此来吸引我点击打开应用。
\n消息推送通常是指网站的运营工作等人员,通过某种工具对用户当前网页或移动设备 APP 进行的主动消息推送。
\n消息推送一般又分为 Web 端消息推送和移动端消息推送。
\n移动端消息推送示例:
\n\nWeb 端消息推送示例:
\n\n在具体实现之前,咱们再来分析一下前边的需求,其实功能很简单,只要触发某个事件(主动分享了资源或者后台主动推送消息),Web 页面的通知小红点就会实时的 +1
就可以了。
通常在服务端会有若干张消息推送表,用来记录用户触发不同事件所推送不同类型的消息,前端主动查询(拉)或者被动接收(推)用户所有未读的消息数。
\n\n消息推送无非是推(push)和拉(pull)两种形式,下边我们逐个了解下。
\n轮询(polling) 应该是实现消息推送方案中最简单的一种,这里我们暂且将轮询分为短轮询和长轮询。
\n短轮询很好理解,指定的时间间隔,由浏览器向服务器发出 HTTP 请求,服务器实时返回未读消息数据给客户端,浏览器再做渲染显示。
\n一个简单的 JS 定时器就可以搞定,每秒钟请求一次未读消息数接口,返回的数据展示即可。
\nsetInterval(() => {\n // 方法请求\n messageCount().then((res) => {\n if (res.code === 200) {\n this.messageCount = res.data;\n }\n });\n}, 1000);\n
效果还是可以的,短轮询实现固然简单,缺点也是显而易见,由于推送数据并不会频繁变更,无论后端此时是否有新的消息产生,客户端都会进行请求,势必会对服务端造成很大压力,浪费带宽和服务器资源。
\n长轮询是对上边短轮询的一种改进版本,在尽可能减少对服务器资源浪费的同时,保证消息的相对实时性。长轮询在中间件中应用的很广泛,比如 Nacos 和 Apollo 配置中心,消息队列 Kafka、RocketMQ 中都有用到长轮询。
\nNacos 配置中心交互模型是 push 还是 pull?一文中我详细介绍过 Nacos 长轮询的实现原理,感兴趣的小伙伴可以瞅瞅。
\n长轮询其实原理跟轮询差不多,都是采用轮询的方式。不过,如果服务端的数据没有发生变更,会 一直 hold 住请求,直到服务端的数据发生变化,或者等待一定时间超时才会返回。返回后,客户端又会立即再次发起下一次长轮询。
\n这次我使用 Apollo 配置中心实现长轮询的方式,应用了一个类DeferredResult
,它是在 Servlet3.0 后经过 Spring 封装提供的一种异步请求机制,直意就是延迟结果。
DeferredResult
可以允许容器线程快速释放占用的资源,不阻塞请求线程,以此接受更多的请求提升系统的吞吐量,然后启动异步工作线程处理真正的业务逻辑,处理完成调用DeferredResult.setResult(200)
提交响应结果。
下边我们用长轮询来实现消息推送。
\n因为一个 ID 可能会被多个长轮询请求监听,所以我采用了 Guava 包提供的Multimap
结构存放长轮询,一个 key 可以对应多个 value。一旦监听到 key 发生变化,对应的所有长轮询都会响应。前端得到非请求超时的状态码,知晓数据变更,主动查询未读消息数接口,更新页面数据。
@Controller\n@RequestMapping(\"/polling\")\npublic class PollingController {\n\n // 存放监听某个Id的长轮询集合\n // 线程同步结构\n public static Multimap<String, DeferredResult<String>> watchRequests = Multimaps.synchronizedMultimap(HashMultimap.create());\n\n /**\n * 设置监听\n */\n @GetMapping(path = \"watch/{id}\")\n @ResponseBody\n public DeferredResult<String> watch(@PathVariable String id) {\n // 延迟对象设置超时时间\n DeferredResult<String> deferredResult = new DeferredResult<>(TIME_OUT);\n // 异步请求完成时移除 key,防止内存溢出\n deferredResult.onCompletion(() -> {\n watchRequests.remove(id, deferredResult);\n });\n // 注册长轮询请求\n watchRequests.put(id, deferredResult);\n return deferredResult;\n }\n\n /**\n * 变更数据\n */\n @GetMapping(path = \"publish/{id}\")\n @ResponseBody\n public String publish(@PathVariable String id) {\n // 数据变更 取出监听ID的所有长轮询请求,并一一响应处理\n if (watchRequests.containsKey(id)) {\n Collection<DeferredResult<String>> deferredResults = watchRequests.get(id);\n for (DeferredResult<String> deferredResult : deferredResults) {\n deferredResult.setResult(\"我更新了\" + new Date());\n }\n }\n return \"success\";\n }\n
当请求超过设置的超时时间,会抛出AsyncRequestTimeoutException
异常,这里直接用@ControllerAdvice
全局捕获统一返回即可,前端获取约定好的状态码后再次发起长轮询请求,如此往复调用。
@ControllerAdvice\npublic class AsyncRequestTimeoutHandler {\n\n @ResponseStatus(HttpStatus.NOT_MODIFIED)\n @ResponseBody\n @ExceptionHandler(AsyncRequestTimeoutException.class)\n public String asyncRequestTimeoutHandler(AsyncRequestTimeoutException e) {\n System.out.println(\"异步请求超时\");\n return \"304\";\n }\n}\n
我们来测试一下,首先页面发起长轮询请求/polling/watch/10086
监听消息更变,请求被挂起,不变更数据直至超时,再次发起了长轮询请求;紧接着手动变更数据/polling/publish/10086
,长轮询得到响应,前端处理业务逻辑完成后再次发起请求,如此循环往复。
长轮询相比于短轮询在性能上提升了很多,但依然会产生较多的请求,这是它的一点不完美的地方。
\niframe 流就是在页面中插入一个隐藏的<iframe>
标签,通过在src
中请求消息数量 API 接口,由此在服务端和客户端之间创建一条长连接,服务端持续向iframe
传输数据。
传输的数据通常是 HTML、或是内嵌的 JavaScript 脚本,来达到实时更新页面的效果。
\n\n这种方式实现简单,前端只要一个<iframe>
标签搞定了
<iframe src=\"/iframe/message\" style=\"display:none\"></iframe>\n
服务端直接组装 HTML、JS 脚本数据向 response 写入就行了
\n@Controller\n@RequestMapping(\"/iframe\")\npublic class IframeController {\n @GetMapping(path = \"message\")\n public void message(HttpServletResponse response) throws IOException, InterruptedException {\n while (true) {\n response.setHeader(\"Pragma\", \"no-cache\");\n response.setDateHeader(\"Expires\", 0);\n response.setHeader(\"Cache-Control\", \"no-cache,no-store\");\n response.setStatus(HttpServletResponse.SC_OK);\n response.getWriter().print(\" <script type=\\\"text/javascript\\\">\\n\" +\n \"parent.document.getElementById('clock').innerHTML = \\\"\" + count.get() + \"\\\";\" +\n \"parent.document.getElementById('count').innerHTML = \\\"\" + count.get() + \"\\\";\" +\n \"</script>\");\n }\n }\n}\n
iframe 流的服务器开销很大,而且 IE、Chrome 等浏览器一直会处于 loading 状态,图标会不停旋转,简直是强迫症杀手。
\n\niframe 流非常不友好,强烈不推荐。
\n很多人可能不知道,服务端向客户端推送消息,其实除了可以用WebSocket
这种耳熟能详的机制外,还有一种服务器发送事件(Server-Sent Events),简称 SSE。这是一种服务器端到客户端(浏览器)的单向消息推送。
大名鼎鼎的 ChatGPT 就是采用的 SSE。对于需要长时间等待响应的对话场景,ChatGPT 采用了一种巧妙的策略:它会将已经计算出的数据“推送”给用户,并利用 SSE 技术在计算过程中持续返回数据。这样做的好处是可以避免用户因等待时间过长而选择关闭页面。
\n\nSSE 基于 HTTP 协议的,我们知道一般意义上的 HTTP 协议是无法做到服务端主动向客户端推送消息的,但 SSE 是个例外,它变换了一种思路。
\n\nSSE 在服务器和客户端之间打开一个单向通道,服务端响应的不再是一次性的数据包而是text/event-stream
类型的数据流信息,在有数据变更时从服务器流式传输到客户端。
整体的实现思路有点类似于在线视频播放,视频流会连续不断的推送到浏览器,你也可以理解成,客户端在完成一次用时很长(网络不畅)的下载。
\n\nSSE 与 WebSocket 作用相似,都可以建立服务端与浏览器之间的通信,实现服务端向客户端推送消息,但还是有些许不同:
\nSSE 与 WebSocket 该如何选择?
\n\n\n技术并没有好坏之分,只有哪个更合适
\n
SSE 好像一直不被大家所熟知,一部分原因是出现了 WebSocket,这个提供了更丰富的协议来执行双向、全双工通信。对于游戏、即时通信以及需要双向近乎实时更新的场景,拥有双向通道更具吸引力。
\n但是,在某些情况下,不需要从客户端发送数据。而你只需要一些服务器操作的更新。比如:站内信、未读消息数、状态更新、股票行情、监控数量等场景,SEE 不管是从实现的难易和成本上都更加有优势。此外,SSE 具有 WebSocket 在设计上缺乏的多种功能,例如:自动重新连接、事件 ID 和发送任意事件的能力。
\n前端只需进行一次 HTTP 请求,带上唯一 ID,打开事件流,监听服务端推送的事件就可以了
\n<script>\n let source = null;\n let userId = 7777\n if (window.EventSource) {\n // 建立连接\n source = new EventSource('http://localhost:7777/sse/sub/'+userId);\n setMessageInnerHTML(\"连接用户=\" + userId);\n /**\n * 连接一旦建立,就会触发open事件\n * 另一种写法:source.onopen = function (event) {}\n */\n source.addEventListener('open', function (e) {\n setMessageInnerHTML(\"建立连接。。。\");\n }, false);\n /**\n * 客户端收到服务器发来的数据\n * 另一种写法:source.onmessage = function (event) {}\n */\n source.addEventListener('message', function (e) {\n setMessageInnerHTML(e.data);\n });\n } else {\n setMessageInnerHTML(\"你的浏览器不支持SSE\");\n }\n</script>\n
服务端的实现更简单,创建一个SseEmitter
对象放入sseEmitterMap
进行管理
private static Map<String, SseEmitter> sseEmitterMap = new ConcurrentHashMap<>();\n\n/**\n * 创建连接\n */\npublic static SseEmitter connect(String userId) {\n try {\n // 设置超时时间,0表示不过期。默认30秒\n SseEmitter sseEmitter = new SseEmitter(0L);\n // 注册回调\n sseEmitter.onCompletion(completionCallBack(userId));\n sseEmitter.onError(errorCallBack(userId));\n sseEmitter.onTimeout(timeoutCallBack(userId));\n sseEmitterMap.put(userId, sseEmitter);\n count.getAndIncrement();\n return sseEmitter;\n } catch (Exception e) {\n log.info(\"创建新的sse连接异常,当前用户:{}\", userId);\n }\n return null;\n}\n\n/**\n * 给指定用户发送消息\n */\npublic static void sendMessage(String userId, String message) {\n\n if (sseEmitterMap.containsKey(userId)) {\n try {\n sseEmitterMap.get(userId).send(message);\n } catch (IOException e) {\n log.error(\"用户[{}]推送异常:{}\", userId, e.getMessage());\n removeUser(userId);\n }\n }\n}\n
注意: SSE 不支持 IE 浏览器,对其他主流浏览器兼容性做的还不错。
\n\nWebsocket 应该是大家都比较熟悉的一种实现消息推送的方式,上边我们在讲 SSE 的时候也和 Websocket 进行过比较。
\n是一种在 TCP 连接上进行全双工通信的协议,建立客户端和服务器之间的通信渠道。浏览器和服务器仅需一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。
\n\nWebSocket 的工作过程可以分为以下几个步骤:
\nUpgrade: websocket
和 Sec-WebSocket-Key
等字段,表示要求升级协议为 WebSocket;Connection: Upgrade
和 Sec-WebSocket-Accept: xxx
等字段、表示成功升级到 WebSocket 协议。另外,建立 WebSocket 连接之后,通过心跳机制来保持 WebSocket 连接的稳定性和活跃性。
\nSpringBoot 整合 Websocket,先引入 Websocket 相关的工具包,和 SSE 相比额外的开发成本。
\n<!-- 引入websocket -->\n<dependency>\n <groupId>org.springframework.boot</groupId>\n <artifactId>spring-boot-starter-websocket</artifactId>\n</dependency>\n
服务端使用@ServerEndpoint
注解标注当前类为一个 WebSocket 服务器,客户端可以通过ws://localhost:7777/webSocket/10086
来连接到 WebSocket 服务器端。
@Component\n@Slf4j\n@ServerEndpoint(\"/websocket/{userId}\")\npublic class WebSocketServer {\n //与某个客户端的连接会话,需要通过它来给客户端发送数据\n private Session session;\n private static final CopyOnWriteArraySet<WebSocketServer> webSockets = new CopyOnWriteArraySet<>();\n // 用来存在线连接数\n private static final Map<String, Session> sessionPool = new HashMap<String, Session>();\n /**\n * 链接成功调用的方法\n */\n @OnOpen\n public void onOpen(Session session, @PathParam(value = \"userId\") String userId) {\n try {\n this.session = session;\n webSockets.add(this);\n sessionPool.put(userId, session);\n log.info(\"websocket消息: 有新的连接,总数为:\" + webSockets.size());\n } catch (Exception e) {\n }\n }\n /**\n * 收到客户端消息后调用的方法\n */\n @OnMessage\n public void onMessage(String message) {\n log.info(\"websocket消息: 收到客户端消息:\" + message);\n }\n /**\n * 此为单点消息\n */\n public void sendOneMessage(String userId, String message) {\n Session session = sessionPool.get(userId);\n if (session != null && session.isOpen()) {\n try {\n log.info(\"websocket消: 单点消息:\" + message);\n session.getAsyncRemote().sendText(message);\n } catch (Exception e) {\n e.printStackTrace();\n }\n }\n }\n}\n
前端初始化打开 WebSocket 连接,并监听连接状态,接收服务端数据或向服务端发送数据。
\n<script>\n var ws = new WebSocket('ws://localhost:7777/webSocket/10086');\n // 获取连接状态\n console.log('ws连接状态:' + ws.readyState);\n //监听是否连接成功\n ws.onopen = function () {\n console.log('ws连接状态:' + ws.readyState);\n //连接成功则发送一个数据\n ws.send('test1');\n }\n // 接听服务器发回的信息并处理展示\n ws.onmessage = function (data) {\n console.log('接收到来自服务器的消息:');\n console.log(data);\n //完成通信后关闭WebSocket连接\n ws.close();\n }\n // 监听连接关闭事件\n ws.onclose = function () {\n // 监听整个过程中websocket的状态\n console.log('ws连接状态:' + ws.readyState);\n }\n // 监听并处理error事件\n ws.onerror = function (error) {\n console.log(error);\n }\n function sendMessage() {\n var content = $(\"#message\").val();\n $.ajax({\n url: '/socket/publish?userId=10086&message=' + content,\n type: 'GET',\n data: { \"id\": \"7777\", \"content\": content },\n success: function (data) {\n console.log(data)\n }\n })\n }\n</script>\n
页面初始化建立 WebSocket 连接,之后就可以进行双向通信了,效果还不错。
\n\n什么是 MQTT 协议?
\nMQTT (Message Queue Telemetry Transport)是一种基于发布/订阅(publish/subscribe)模式的轻量级通讯协议,通过订阅相应的主题来获取消息,是物联网(Internet of Thing)中的一个标准传输协议。
\n该协议将消息的发布者(publisher)与订阅者(subscriber)进行分离,因此可以在不可靠的网络环境中,为远程连接的设备提供可靠的消息服务,使用方式与传统的 MQ 有点类似。
\n\nTCP 协议位于传输层,MQTT 协议位于应用层,MQTT 协议构建于 TCP/IP 协议上,也就是说只要支持 TCP/IP 协议栈的地方,都可以使用 MQTT 协议。
\n为什么要用 MQTT 协议?
\nMQTT 协议为什么在物联网(IOT)中如此受偏爱?而不是其它协议,比如我们更为熟悉的 HTTP 协议呢?
\n具体的 MQTT 协议介绍和实践,这里我就不再赘述了,大家可以参考我之前的两篇文章,里边写的也都很详细了。
\n\n\n以下内容为 JavaGuide 补充
\n
| | 介绍 | 优点 | 缺点 |
\n|
\n\n作者:Hollis
\n\n
语法糖是大厂 Java 面试常问的一个知识点。
\n本文从 Java 编译原理角度,深入字节码及 class 文件,抽丝剥茧,了解 Java 中的语法糖原理及用法,帮助大家在学会如何使用 Java 语法糖的同时,了解这些语法糖背后的原理。
\n语法糖(Syntactic Sugar) 也称糖衣语法,是英国计算机学家 Peter.J.Landin 发明的一个术语,指在计算机语言中添加的某种语法,这种语法对语言的功能并没有影响,但是更方便程序员使用。简而言之,语法糖让程序更加简洁,有更高的可读性。
\n\n\n\n有意思的是,在编程领域,除了语法糖,还有语法盐和语法糖精的说法,篇幅有限这里不做扩展了。
\n
我们所熟知的编程语言中几乎都有语法糖。作者认为,语法糖的多少是评判一个语言够不够牛逼的标准之一。很多人说 Java 是一个“低糖语言”,其实从 Java 7 开始 Java 语言层面上一直在添加各种糖,主要是在“Project Coin”项目下研发。尽管现在 Java 有人还是认为现在的 Java 是低糖,未来还会持续向着“高糖”的方向发展。
\n前面提到过,语法糖的存在主要是方便开发人员使用。但其实, Java 虚拟机并不支持这些语法糖。这些语法糖在编译阶段就会被还原成简单的基础语法结构,这个过程就是解语法糖。
\n说到编译,大家肯定都知道,Java 语言中,javac
命令可以将后缀名为.java
的源文件编译为后缀名为.class
的可以运行于 Java 虚拟机的字节码。如果你去看com.sun.tools.javac.main.JavaCompiler
的源码,你会发现在compile()
中有一个步骤就是调用desugar()
,这个方法就是负责解语法糖的实现的。
Java 中最常用的语法糖主要有泛型、变长参数、条件编译、自动拆装箱、内部类等。本文主要来分析下这些语法糖背后的原理。一步一步剥去糖衣,看看其本质。
\n我们这里会用到反编译,你可以通过 Decompilers online 对 Class 文件进行在线反编译。
\n前面提到过,从 Java 7 开始,Java 语言中的语法糖在逐渐丰富,其中一个比较重要的就是 Java 7 中switch
开始支持String
。
在开始之前先科普下,Java 中的switch
自身原本就支持基本类型。比如int
、char
等。对于int
类型,直接进行数值的比较。对于char
类型则是比较其 ascii 码。所以,对于编译器来说,switch
中其实只能使用整型,任何类型的比较都要转换成整型。比如byte
。short
,char
(ascii 码是整型)以及int
。
那么接下来看下switch
对String
的支持,有以下代码:
public class switchDemoString {\n public static void main(String[] args) {\n String str = \"world\";\n switch (str) {\n case \"hello\":\n System.out.println(\"hello\");\n break;\n case \"world\":\n System.out.println(\"world\");\n break;\n default:\n break;\n }\n }\n}\n
反编译后内容如下:
\npublic class switchDemoString\n{\n public switchDemoString()\n {\n }\n public static void main(String args[])\n {\n String str = \"world\";\n String s;\n switch((s = str).hashCode())\n {\n default:\n break;\n case 99162322:\n if(s.equals(\"hello\"))\n System.out.println(\"hello\");\n break;\n case 113318802:\n if(s.equals(\"world\"))\n System.out.println(\"world\");\n break;\n }\n }\n}\n
看到这个代码,你知道原来 字符串的 switch 是通过equals()
和hashCode()
方法来实现的。 还好hashCode()
方法返回的是int
,而不是long
。
仔细看下可以发现,进行switch
的实际是哈希值,然后通过使用equals
方法比较进行安全检查,这个检查是必要的,因为哈希可能会发生碰撞。因此它的性能是不如使用枚举进行 switch
或者使用纯整数常量,但这也不是很差。
我们都知道,很多语言都是支持泛型的,但是很多人不知道的是,不同的编译器对于泛型的处理方式是不同的,通常情况下,一个编译器处理泛型有两种方式:Code specialization
和Code sharing
。C++和 C#是使用Code specialization
的处理机制,而 Java 使用的是Code sharing
的机制。
\n\nCode sharing 方式为每个泛型类型创建唯一的字节码表示,并且将该泛型类型的实例都映射到这个唯一的字节码表示上。将多种泛型类形实例映射到唯一的字节码表示是通过类型擦除(
\ntype erasue
)实现的。
也就是说,对于 Java 虚拟机来说,他根本不认识Map<String, String> map
这样的语法。需要在编译阶段通过类型擦除的方式进行解语法糖。
类型擦除的主要过程如下:1.将所有的泛型参数用其最左边界(最顶级的父类型)类型替换。 2.移除所有的类型参数。
\n以下代码:
\nMap<String, String> map = new HashMap<String, String>();\nmap.put(\"name\", \"hollis\");\nmap.put(\"wechat\", \"Hollis\");\nmap.put(\"blog\", \"www.hollischuang.com\");\n
解语法糖之后会变成:
\nMap map = new HashMap();\nmap.put(\"name\", \"hollis\");\nmap.put(\"wechat\", \"Hollis\");\nmap.put(\"blog\", \"www.hollischuang.com\");\n
以下代码:
\npublic static <A extends Comparable<A>> A max(Collection<A> xs) {\n Iterator<A> xi = xs.iterator();\n A w = xi.next();\n while (xi.hasNext()) {\n A x = xi.next();\n if (w.compareTo(x) < 0)\n w = x;\n }\n return w;\n}\n
类型擦除后会变成:
\n public static Comparable max(Collection xs){\n Iterator xi = xs.iterator();\n Comparable w = (Comparable)xi.next();\n while(xi.hasNext())\n {\n Comparable x = (Comparable)xi.next();\n if(w.compareTo(x) < 0)\n w = x;\n }\n return w;\n}\n
虚拟机中没有泛型,只有普通类和普通方法,所有泛型类的类型参数在编译时都会被擦除,泛型类并没有自己独有的Class
类对象。比如并不存在List<String>.class
或是List<Integer>.class
,而只有List.class
。
自动装箱就是 Java 自动将原始类型值转换成对应的对象,比如将 int 的变量转换成 Integer 对象,这个过程叫做装箱,反之将 Integer 对象转换成 int 类型值,这个过程叫做拆箱。因为这里的装箱和拆箱是自动进行的非人为转换,所以就称作为自动装箱和拆箱。原始类型 byte, short, char, int, long, float, double 和 boolean 对应的封装类为 Byte, Short, Character, Integer, Long, Float, Double, Boolean。
\n先来看个自动装箱的代码:
\n public static void main(String[] args) {\n int i = 10;\n Integer n = i;\n}\n
反编译后代码如下:
\npublic static void main(String args[])\n{\n int i = 10;\n Integer n = Integer.valueOf(i);\n}\n
再来看个自动拆箱的代码:
\npublic static void main(String[] args) {\n\n Integer i = 10;\n int n = i;\n}\n
反编译后代码如下:
\npublic static void main(String args[])\n{\n Integer i = Integer.valueOf(10);\n int n = i.intValue();\n}\n
从反编译得到内容可以看出,在装箱的时候自动调用的是Integer
的valueOf(int)
方法。而在拆箱的时候自动调用的是Integer
的intValue
方法。
所以,装箱过程是通过调用包装器的 valueOf 方法实现的,而拆箱过程是通过调用包装器的 xxxValue 方法实现的。
\n可变参数(variable arguments
)是在 Java 1.5 中引入的一个特性。它允许一个方法把任意数量的值作为参数。
看下以下可变参数代码,其中 print
方法接收可变参数:
public static void main(String[] args)\n {\n print(\"Holis\", \"公众号:Hollis\", \"博客:www.hollischuang.com\", \"QQ:907607222\");\n }\n\npublic static void print(String... strs)\n{\n for (int i = 0; i < strs.length; i++)\n {\n System.out.println(strs[i]);\n }\n}\n
反编译后代码:
\n public static void main(String args[])\n{\n print(new String[] {\n \"Holis\", \"\\u516C\\u4F17\\u53F7:Hollis\", \"\\u535A\\u5BA2\\uFF1Awww.hollischuang.com\", \"QQ\\uFF1A907607222\"\n });\n}\n\npublic static transient void print(String strs[])\n{\n for(int i = 0; i < strs.length; i++)\n System.out.println(strs[i]);\n\n}\n
从反编译后代码可以看出,可变参数在被使用的时候,他首先会创建一个数组,数组的长度就是调用该方法是传递的实参的个数,然后再把参数值全部放到这个数组当中,然后再把这个数组作为参数传递到被调用的方法中。
\nJava SE5 提供了一种新的类型-Java 的枚举类型,关键字enum
可以将一组具名的值的有限集合创建为一种新的类型,而这些具名的值可以作为常规的程序组件使用,这是一种非常有用的功能。
要想看源码,首先得有一个类吧,那么枚举类型到底是什么类呢?是enum
吗?答案很明显不是,enum
就和class
一样,只是一个关键字,他并不是一个类,那么枚举是由什么类维护的呢,我们简单的写一个枚举:
public enum t {\n SPRING,SUMMER;\n}\n
然后我们使用反编译,看看这段代码到底是怎么实现的,反编译后代码内容如下:
\npublic final class T extends Enum\n{\n private T(String s, int i)\n {\n super(s, i);\n }\n public static T[] values()\n {\n T at[];\n int i;\n T at1[];\n System.arraycopy(at = ENUM$VALUES, 0, at1 = new T[i = at.length], 0, i);\n return at1;\n }\n\n public static T valueOf(String s)\n {\n return (T)Enum.valueOf(demo/T, s);\n }\n\n public static final T SPRING;\n public static final T SUMMER;\n private static final T ENUM$VALUES[];\n static\n {\n SPRING = new T(\"SPRING\", 0);\n SUMMER = new T(\"SUMMER\", 1);\n ENUM$VALUES = (new T[] {\n SPRING, SUMMER\n });\n }\n}\n
通过反编译后代码我们可以看到,public final class T extends Enum
,说明,该类是继承了Enum
类的,同时final
关键字告诉我们,这个类也是不能被继承的。
当我们使用enum
来定义一个枚举类型的时候,编译器会自动帮我们创建一个final
类型的类继承Enum
类,所以枚举类型不能被继承。
内部类又称为嵌套类,可以把内部类理解为外部类的一个普通成员。
\n内部类之所以也是语法糖,是因为它仅仅是一个编译时的概念,outer.java
里面定义了一个内部类inner
,一旦编译成功,就会生成两个完全不同的.class
文件了,分别是outer.class
和outer$inner.class
。所以内部类的名字完全可以和它的外部类名字相同。
public class OutterClass {\n private String userName;\n\n public String getUserName() {\n return userName;\n }\n\n public void setUserName(String userName) {\n this.userName = userName;\n }\n\n public static void main(String[] args) {\n\n }\n\n class InnerClass{\n private String name;\n\n public String getName() {\n return name;\n }\n\n public void setName(String name) {\n this.name = name;\n }\n }\n}\n
以上代码编译后会生成两个 class 文件:OutterClass$InnerClass.class
、OutterClass.class
。当我们尝试对OutterClass.class
文件进行反编译的时候,命令行会打印以下内容:Parsing OutterClass.class...Parsing inner class OutterClass$InnerClass.class... Generating OutterClass.jad
。他会把两个文件全部进行反编译,然后一起生成一个OutterClass.jad
文件。文件内容如下:
public class OutterClass\n{\n class InnerClass\n {\n public String getName()\n {\n return name;\n }\n public void setName(String name)\n {\n this.name = name;\n }\n private String name;\n final OutterClass this$0;\n\n InnerClass()\n {\n this.this$0 = OutterClass.this;\n super();\n }\n }\n\n public OutterClass()\n {\n }\n public String getUserName()\n {\n return userName;\n }\n public void setUserName(String userName){\n this.userName = userName;\n }\n public static void main(String args1[])\n {\n }\n private String userName;\n}\n
—般情况下,程序中的每一行代码都要参加编译。但有时候出于对程序代码优化的考虑,希望只对其中一部分内容进行编译,此时就需要在程序中加上条件,让编译器只对满足条件的代码进行编译,将不满足条件的代码舍弃,这就是条件编译。
\n如在 C 或 CPP 中,可以通过预处理语句来实现条件编译。其实在 Java 中也可实现条件编译。我们先来看一段代码:
\npublic class ConditionalCompilation {\n public static void main(String[] args) {\n final boolean DEBUG = true;\n if(DEBUG) {\n System.out.println(\"Hello, DEBUG!\");\n }\n\n final boolean ONLINE = false;\n\n if(ONLINE){\n System.out.println(\"Hello, ONLINE!\");\n }\n }\n}\n
反编译后代码如下:
\npublic class ConditionalCompilation\n{\n\n public ConditionalCompilation()\n {\n }\n\n public static void main(String args[])\n {\n boolean DEBUG = true;\n System.out.println(\"Hello, DEBUG!\");\n boolean ONLINE = false;\n }\n}\n
首先,我们发现,在反编译后的代码中没有System.out.println(\"Hello, ONLINE!\");
,这其实就是条件编译。当if(ONLINE)
为 false 的时候,编译器就没有对其内的代码进行编译。
所以,Java 语法的条件编译,是通过判断条件为常量的 if 语句实现的。其原理也是 Java 语言的语法糖。根据 if 判断条件的真假,编译器直接把分支为 false 的代码块消除。通过该方式实现的条件编译,必须在方法体内实现,而无法在正整个 Java 类的结构或者类的属性上进行条件编译,这与 C/C++的条件编译相比,确实更有局限性。在 Java 语言设计之初并没有引入条件编译的功能,虽有局限,但是总比没有更强。
\n在 Java 中,assert
关键字是从 JAVA SE 1.4 引入的,为了避免和老版本的 Java 代码中使用了assert
关键字导致错误,Java 在执行的时候默认是不启动断言检查的(这个时候,所有的断言语句都将忽略!),如果要开启断言检查,则需要用开关-enableassertions
或-ea
来开启。
看一段包含断言的代码:
\npublic class AssertTest {\n public static void main(String args[]) {\n int a = 1;\n int b = 1;\n assert a == b;\n System.out.println(\"公众号:Hollis\");\n assert a != b : \"Hollis\";\n System.out.println(\"博客:www.hollischuang.com\");\n }\n}\n
反编译后代码如下:
\npublic class AssertTest {\n public AssertTest()\n {\n }\n public static void main(String args[])\n{\n int a = 1;\n int b = 1;\n if(!$assertionsDisabled && a != b)\n throw new AssertionError();\n System.out.println(\"\\u516C\\u4F17\\u53F7\\uFF1AHollis\");\n if(!$assertionsDisabled && a == b)\n {\n throw new AssertionError(\"Hollis\");\n } else\n {\n System.out.println(\"\\u535A\\u5BA2\\uFF1Awww.hollischuang.com\");\n return;\n }\n}\n\nstatic final boolean $assertionsDisabled = !com/hollis/suguar/AssertTest.desiredAssertionStatus();\n\n}\n
很明显,反编译之后的代码要比我们自己的代码复杂的多。所以,使用了 assert 这个语法糖我们节省了很多代码。其实断言的底层实现就是 if 语言,如果断言结果为 true,则什么都不做,程序继续执行,如果断言结果为 false,则程序抛出 AssertError 来打断程序的执行。-enableassertions
会设置$assertionsDisabled 字段的值。
在 java 7 中,数值字面量,不管是整数还是浮点数,都允许在数字之间插入任意多个下划线。这些下划线不会对字面量的数值产生影响,目的就是方便阅读。
\n比如:
\npublic class Test {\n public static void main(String... args) {\n int i = 10_000;\n System.out.println(i);\n }\n}\n
反编译后:
\npublic class Test\n{\n public static void main(String[] args)\n {\n int i = 10000;\n System.out.println(i);\n }\n}\n
反编译后就是把_
删除了。也就是说 编译器并不认识在数字字面量中的_
,需要在编译阶段把他去掉。
增强 for 循环(for-each
)相信大家都不陌生,日常开发经常会用到的,他会比 for 循环要少写很多代码,那么这个语法糖背后是如何实现的呢?
public static void main(String... args) {\n String[] strs = {\"Hollis\", \"公众号:Hollis\", \"博客:www.hollischuang.com\"};\n for (String s : strs) {\n System.out.println(s);\n }\n List<String> strList = ImmutableList.of(\"Hollis\", \"公众号:Hollis\", \"博客:www.hollischuang.com\");\n for (String s : strList) {\n System.out.println(s);\n }\n}\n
反编译后代码如下:
\npublic static transient void main(String args[])\n{\n String strs[] = {\n \"Hollis\", \"\\u516C\\u4F17\\u53F7\\uFF1AHollis\", \"\\u535A\\u5BA2\\uFF1Awww.hollischuang.com\"\n };\n String args1[] = strs;\n int i = args1.length;\n for(int j = 0; j < i; j++)\n {\n String s = args1[j];\n System.out.println(s);\n }\n\n List strList = ImmutableList.of(\"Hollis\", \"\\u516C\\u4F17\\u53F7\\uFF1AHollis\", \"\\u535A\\u5BA2\\uFF1Awww.hollischuang.com\");\n String s;\n for(Iterator iterator = strList.iterator(); iterator.hasNext(); System.out.println(s))\n s = (String)iterator.next();\n\n}\n
代码很简单,for-each 的实现原理其实就是使用了普通的 for 循环和迭代器。
\nJava 里,对于文件操作 IO 流、数据库连接等开销非常昂贵的资源,用完之后必须及时通过 close 方法将其关闭,否则资源会一直处于打开状态,可能会导致内存泄露等问题。
\n关闭资源的常用方式就是在finally
块里是释放,即调用close
方法。比如,我们经常会写这样的代码:
public static void main(String[] args) {\n BufferedReader br = null;\n try {\n String line;\n br = new BufferedReader(new FileReader(\"d:\\\\hollischuang.xml\"));\n while ((line = br.readLine()) != null) {\n System.out.println(line);\n }\n } catch (IOException e) {\n // handle exception\n } finally {\n try {\n if (br != null) {\n br.close();\n }\n } catch (IOException ex) {\n // handle exception\n }\n }\n}\n
从 Java 7 开始,jdk 提供了一种更好的方式关闭资源,使用try-with-resources
语句,改写一下上面的代码,效果如下:
public static void main(String... args) {\n try (BufferedReader br = new BufferedReader(new FileReader(\"d:\\\\ hollischuang.xml\"))) {\n String line;\n while ((line = br.readLine()) != null) {\n System.out.println(line);\n }\n } catch (IOException e) {\n // handle exception\n }\n}\n
看,这简直是一大福音啊,虽然我之前一般使用IOUtils
去关闭流,并不会使用在finally
中写很多代码的方式,但是这种新的语法糖看上去好像优雅很多呢。看下他的背后:
public static transient void main(String args[])\n {\n BufferedReader br;\n Throwable throwable;\n br = new BufferedReader(new FileReader(\"d:\\\\ hollischuang.xml\"));\n throwable = null;\n String line;\n try\n {\n while((line = br.readLine()) != null)\n System.out.println(line);\n }\n catch(Throwable throwable2)\n {\n throwable = throwable2;\n throw throwable2;\n }\n if(br != null)\n if(throwable != null)\n try\n {\n br.close();\n }\n catch(Throwable throwable1)\n {\n throwable.addSuppressed(throwable1);\n }\n else\n br.close();\n break MISSING_BLOCK_LABEL_113;\n Exception exception;\n exception;\n if(br != null)\n if(throwable != null)\n try\n {\n br.close();\n }\n catch(Throwable throwable3)\n {\n throwable.addSuppressed(throwable3);\n }\n else\n br.close();\n throw exception;\n IOException ioexception;\n ioexception;\n }\n}\n
其实背后的原理也很简单,那些我们没有做的关闭资源的操作,编译器都帮我们做了。所以,再次印证了,语法糖的作用就是方便程序员的使用,但最终还是要转成编译器认识的语言。
\n关于 lambda 表达式,有人可能会有质疑,因为网上有人说他并不是语法糖。其实我想纠正下这个说法。Lambda 表达式不是匿名内部类的语法糖,但是他也是一个语法糖。实现方式其实是依赖了几个 JVM 底层提供的 lambda 相关 api。
\n先来看一个简单的 lambda 表达式。遍历一个 list:
\npublic static void main(String... args) {\n List<String> strList = ImmutableList.of(\"Hollis\", \"公众号:Hollis\", \"博客:www.hollischuang.com\");\n\n strList.forEach( s -> { System.out.println(s); } );\n}\n
为啥说他并不是内部类的语法糖呢,前面讲内部类我们说过,内部类在编译之后会有两个 class 文件,但是,包含 lambda 表达式的类编译后只有一个文件。
\n反编译后代码如下:
\npublic static /* varargs */ void main(String ... args) {\n ImmutableList strList = ImmutableList.of((Object)\"Hollis\", (Object)\"\\u516c\\u4f17\\u53f7\\uff1aHollis\", (Object)\"\\u535a\\u5ba2\\uff1awww.hollischuang.com\");\n strList.forEach((Consumer<String>)LambdaMetafactory.metafactory(null, null, null, (Ljava/lang/Object;)V, lambda$main$0(java.lang.String ), (Ljava/lang/String;)V)());\n}\n\nprivate static /* synthetic */ void lambda$main$0(String s) {\n System.out.println(s);\n}\n
可以看到,在forEach
方法中,其实是调用了java.lang.invoke.LambdaMetafactory#metafactory
方法,该方法的第四个参数 implMethod
指定了方法实现。可以看到这里其实是调用了一个lambda$main$0
方法进行了输出。
再来看一个稍微复杂一点的,先对 List 进行过滤,然后再输出:
\npublic static void main(String... args) {\n List<String> strList = ImmutableList.of(\"Hollis\", \"公众号:Hollis\", \"博客:www.hollischuang.com\");\n\n List HollisList = strList.stream().filter(string -> string.contains(\"Hollis\")).collect(Collectors.toList());\n\n HollisList.forEach( s -> { System.out.println(s); } );\n}\n
反编译后代码如下:
\npublic static /* varargs */ void main(String ... args) {\n ImmutableList strList = ImmutableList.of((Object)\"Hollis\", (Object)\"\\u516c\\u4f17\\u53f7\\uff1aHollis\", (Object)\"\\u535a\\u5ba2\\uff1awww.hollischuang.com\");\n List<Object> HollisList = strList.stream().filter((Predicate<String>)LambdaMetafactory.metafactory(null, null, null, (Ljava/lang/Object;)Z, lambda$main$0(java.lang.String ), (Ljava/lang/String;)Z)()).collect(Collectors.toList());\n HollisList.forEach((Consumer<Object>)LambdaMetafactory.metafactory(null, null, null, (Ljava/lang/Object;)V, lambda$main$1(java.lang.Object ), (Ljava/lang/Object;)V)());\n}\n\nprivate static /* synthetic */ void lambda$main$1(Object s) {\n System.out.println(s);\n}\n\nprivate static /* synthetic */ boolean lambda$main$0(String string) {\n return string.contains(\"Hollis\");\n}\n
两个 lambda 表达式分别调用了lambda$main$1
和lambda$main$0
两个方法。
所以,lambda 表达式的实现其实是依赖了一些底层的 api,在编译阶段,编译器会把 lambda 表达式进行解糖,转换成调用内部 api 的方式。
\n一、当泛型遇到重载
\npublic class GenericTypes {\n\n public static void method(List<String> list) {\n System.out.println(\"invoke method(List<String> list)\");\n }\n\n public static void method(List<Integer> list) {\n System.out.println(\"invoke method(List<Integer> list)\");\n }\n}\n
上面这段代码,有两个重载的函数,因为他们的参数类型不同,一个是List<String>
另一个是List<Integer>
,但是,这段代码是编译通不过的。因为我们前面讲过,参数List<Integer>
和List<String>
编译之后都被擦除了,变成了一样的原生类型 List,擦除动作导致这两个方法的特征签名变得一模一样。
二、当泛型遇到 catch
\n泛型的类型参数不能用在 Java 异常处理的 catch 语句中。因为异常处理是由 JVM 在运行时刻来进行的。由于类型信息被擦除,JVM 是无法区分两个异常类型MyException<String>
和MyException<Integer>
的
三、当泛型内包含静态变量
\npublic class StaticTest{\n public static void main(String[] args){\n GT<Integer> gti = new GT<Integer>();\n gti.var=1;\n GT<String> gts = new GT<String>();\n gts.var=2;\n System.out.println(gti.var);\n }\n}\nclass GT<T>{\n public static int var=0;\n public void nothing(T x){}\n}\n
以上代码输出结果为:2!
\n有些同学可能会误认为泛型类是不同的类,对应不同的字节码,其实
\n由于经过类型擦除,所有的泛型类实例都关联到同一份字节码上,泛型类的静态变量是共享的。上面例子里的GT<Integer>.var
和GT<String>.var
其实是一个变量。
对象相等比较
\npublic static void main(String[] args) {\n Integer a = 1000;\n Integer b = 1000;\n Integer c = 100;\n Integer d = 100;\n System.out.println(\"a == b is \" + (a == b));\n System.out.println((\"c == d is \" + (c == d)));\n}\n
输出结果:
\na == b is false\nc == d is true\n
在 Java 5 中,在 Integer 的操作上引入了一个新功能来节省内存和提高性能。整型对象通过使用相同的对象引用实现了缓存和重用。
\n\n\n适用于整数值区间-128 至 +127。
\n只适用于自动装箱。使用构造函数创建对象不适用。
\n
for (Student stu : students) {\n if (stu.getId() == 2)\n students.remove(stu);\n}\n
会抛出ConcurrentModificationException
异常。
Iterator 是工作在一个独立的线程中,并且拥有一个 mutex 锁。 Iterator 被创建之后会建立一个指向原来对象的单链索引表,当原来的对象数量发生变化时,这个索引表的内容不会同步改变,所以当索引指针往后移动的时候就找不到要迭代的对象,所以按照 fail-fast 原则 Iterator 会马上抛出java.util.ConcurrentModificationException
异常。
所以 Iterator
在工作的时候是不允许被迭代的对象被改变的。但你可以使用 Iterator
本身的方法remove()
来删除对象,Iterator.remove()
方法会在删除当前迭代对象的同时维护索引的一致性。
前面介绍了 12 种 Java 中常用的语法糖。所谓语法糖就是提供给开发人员便于开发的一种语法而已。但是这种语法只有开发人员认识。要想被执行,需要进行解糖,即转成 JVM 认识的语法。当我们把语法糖解糖之后,你就会发现其实我们日常使用的这些方便的语法,其实都是一些其他更简单的语法构成的。
\n有了这些语法糖,我们在日常开发的时候可以大大提升效率,但是同时也要避过度使用。使用之前最好了解下原理,避免掉坑。
\n\n", "image": "https://oss.javaguide.cn/github/javaguide/java/basis/syntactic-sugar/image-20220818175953954.png", "date_published": "2022-08-18T12:45:01.000Z", "date_modified": "2024-01-05T08:05:16.000Z", "authors": [], "tags": [ "Java" ] }, { "title": "权限系统设计详解", "url": "https://javaguide.cn/system-design/security/design-of-authority-system.html", "id": "https://javaguide.cn/system-design/security/design-of-authority-system.html", "summary": "JavaGuide官方知识星球 作者:转转技术团队 原文:https://mp.weixin.qq.com/s/ONMuELjdHYa0yQceTj01Iw 老权限系统的问题与现状 转转公司在过去并没有一个统一的权限管理系统,权限管理由各业务自行研发或是使用其他业务的权限系统,权限管理的不统一带来了不少问题: 各业务重复造轮子,维护成本高 各系统只解决...", "content_html": "\n\n\n作者:转转技术团队
\n\n
转转公司在过去并没有一个统一的权限管理系统,权限管理由各业务自行研发或是使用其他业务的权限系统,权限管理的不统一带来了不少问题:
\n基于上述问题,去年底公司启动建设转转统一权限系统,目标是开发一套灵活、易用、安全的权限管理系统,供各业务使用。
\n目前业界主流的权限模型有两种,下面分别介绍下:
\n基于角色的访问控制(Role-Based Access Control,简称 RBAC) 指的是通过用户的角色(Role)授权其相关权限,实现了灵活的访问控制,相比直接授予用户权限,要更加简单、高效、可扩展。
\n一个用户可以拥有若干角色,每一个角色又可以被分配若干权限这样,就构造成“用户-角色-权限” 的授权模型。在这种模型中,用户与角色、角色与权限之间构成了多对多的关系。
\n用一个图来描述如下:
\n\n当使用 RBAC模型
时,通过分析用户的实际情况,基于共同的职责和需求,授予他们不同角色。这种 用户 -> 角色 -> 权限
间的关系,让我们可以不用再单独管理单个用户权限,用户从授予的角色里面获取所需的权限。
以一个简单的场景(Gitlab 的权限系统)为例,用户系统中有 Admin
、Maintainer
、Operator
三种角色,这三种角色分别具备不同的权限,比如只有 Admin
具备创建代码仓库、删除代码仓库的权限,其他的角色都不具备。我们授予某个用户 Admin
这个角色,他就具备了 创建代码仓库 和 删除代码仓库 这两个权限。
通过 RBAC模型
,当存在多个用户拥有相同权限时,我们只需要创建好拥有该权限的角色,然后给不同的用户分配不同的角色,后续只需要修改角色的权限,就能自动修改角色内所有用户的权限。
基于属性的访问控制(Attribute-Based Access Control,简称 ABAC) 是一种比 RBAC模型
更加灵活的授权模型,它的原理是通过各种属性来动态判断一个操作是否可以被允许。这个模型在云系统中使用的比较多,比如 AWS,阿里云等。
考虑下面这些场景的权限控制:
\n可以发现上述的场景通过 RBAC模型
很难去实现,因为 RBAC模型
仅仅描述了用户可以做什么操作,但是操作的条件,以及操作的数据,RBAC模型
本身是没有这些限制的。但这恰恰是 ABAC模型
的长处,ABAC模型
的思想是基于用户、访问的数据的属性、以及各种环境因素去动态计算用户是否有权限进行操作。
在 ABAC模型
中,一个操作是否被允许是基于对象、资源、操作和环境信息共同动态计算决定的。
在 ABAC模型
的决策语句的执行过程中,决策引擎会根据定义好的决策语句,结合对象、资源、操作、环境等因素动态计算出决策结果。每当发生访问请求时,ABAC模型
决策系统都会分析属性值是否与已建立的策略匹配。如果有匹配的策略,访问请求就会被通过。
结合转转的业务现状,RBAC模型
满足了转转绝大部分业务场景,并且开发成本远低于 ABAC模型
的权限系统,所以新权限系统选择了基于 RBAC模型
来实现。对于实在无法满足的业务系统,我们选择了暂时性不支持,这样可以保障新权限系统的快速落地,更快的让业务使用起来。
标准的 RBAC模型
是完全遵守 用户 -> 角色 -> 权限
这个链路的,也就是用户的权限完全由他所拥有的角色来控制,但是这样会有一个缺点,就是给用户加权限必须新增一个角色,导致实际操作起来效率比较低。所以我们在 RBAC模型
的基础上,新增了给用户直接增加权限的能力,也就是说既可以给用户添加角色,也可以给用户直接添加权限。最终用户的权限是由拥有的角色和权限点组合而成。
新权限系统的权限模型:用户最终权限 = 用户拥有的角色带来的权限 + 用户独立配置的权限,两者取并集。
\n新权限系统方案如下图:
\n\n完成上述配置后,就可以进行用户的权限管理了。有两种方式可以给用户加权限:
\n这两种方式的具体设计方案,后文会详细说明。
\n对于权限系统来说,首先需要设计好系统自身的权限管理,也就是需要管理好 ”谁可以进入权限系统,谁可以管理其他系统的权限“,对于权限系统自身的用户,会分为三类:
\n新权限系统中,我们把权限分为两大类,分别是:
\n每个系统中设计了三个默认角色,用来满足基本的权限管理需求,分别如下:
\n\n\n举个栗子:授权管理员 A 可以给 B 用户添加权限,但添加的范围 小于等于 A 用户已拥有的权限。
\n
经过这么区分,把 拥有权限 和 拥有授权能力 ,这两部分给分隔开来,可以满足所有的权限控制的场景。
\n上面介绍了新权限系统的整体设计思想,接下来分别介绍下核心模块的设计
\n把一个新系统接入权限系统有下列步骤:
\n其中,1、2、3 的步骤,都是在系统管理模块完成,具体流程如下图:
\n\n用户可以对系统的基本信息进行增删改查的操作,不同系统之间通过 系统编码
作为唯一区分。同时 系统编码
也会用作于菜单和数据权限编码的前缀,通过这样的设计保证权限编码全局唯一性。
例如系统的编码为 test_online
,那么该系统的菜单编码格式便为 test_online:m_xxx
。
系统管理界面设计如下:
\n\n新权限系统首先对菜单进行了分类,分别是 目录
、菜单
和 操作
,示意如下图
它们分别代表的含义是:
\n菜单管理界面设计如下:
\n\n菜单权限数据的使用,也提供两种方式:
\n角色与用户管理都是可以直接改变用户权限的核心模块,整个设计思路如下图:
\n\n这个模块设计重点是需要考虑到批量操作。无论是通过角色关联用户,还是给用户批量增加/删除/重置权限,批量操作的场景都是系统需要设计好的。
\n除了给其他用户添加权限外,新权限系统同时支持了用户自主申请权限。这个模块除了常规的审批流(申请、审批、查看)等,有一个比较特别的功能,就是如何让用户能选对自己要的权限。所以在该模块的设计上,除了直接选择角色外,还支持通过菜单/数据权限点,反向选择角色,如下图:
\n\n系统操作日志会分为两大类:
\n在新权限系统中,用户所有的操作可以分为三类,分别为新增、更新、删除。所有的模块也可枚举,例如用户管理、角色管理、菜单管理等。明确这些信息后,那么一条日志就可以抽象为:什么人(Who)在什么时间(When)对哪些人(Target)的哪些模块做了哪些操作。
\n这样把所有的记录都入库,就可以方便的进行日志的查看和筛选了。
至此,新权限系统的核心设计思路与模块都已介绍完成,新系统在转转内部有大量的业务接入使用,权限管理相比以前方便了许多。权限系统作为每家公司的一个基础系统,灵活且完备的设计可以助力日后业务的发展更加高效。
\n后续两篇:
\n\n超文本传输协议(HTTP,HyperText Transfer Protocol) 是一种用于传输超文本和多媒体内容的协议,主要是为 Web 浏览器与 Web 服务器之间的通信而设计的。当我们使用浏览器浏览网页的时候,我们网页就是通过 HTTP 请求进行加载的。
\nHTTP 使用客户端-服务器模型,客户端向服务器发送 HTTP Request(请求),服务器响应请求并返回 HTTP Response(响应),整个过程如下图所示。
\n\nHTTP 协议基于 TCP 协议,发送 HTTP 请求之前首先要建立 TCP 连接也就是要经历 3 次握手。目前使用的 HTTP 协议大部分都是 1.1。在 1.1 的协议里面,默认是开启了 Keep-Alive 的,这样的话建立的连接就可以在多次请求中被复用了。
\n另外, HTTP 协议是”无状态”的协议,它无法记录客户端用户的状态,一般我们都是通过 Session 来记录客户端用户的状态。
\nWebSocket 是一种基于 TCP 连接的全双工通信协议,即客户端和服务器可以同时发送和接收数据。
\nWebSocket 协议在 2008 年诞生,2011 年成为国际标准,几乎所有主流较新版本的浏览器都支持该协议。不过,WebSocket 不只能在基于浏览器的应用程序中使用,很多编程语言、框架和服务器都提供了 WebSocket 支持。
\nWebSocket 协议本质上是应用层的协议,用于弥补 HTTP 协议在持久通信能力上的不足。客户端和服务器仅需一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。
\n\n下面是 WebSocket 的常见应用场景:
\nWebSocket 的工作过程可以分为以下几个步骤:
\nUpgrade: websocket
和 Sec-WebSocket-Key
等字段,表示要求升级协议为 WebSocket;Connection: Upgrade
和 Sec-WebSocket-Accept: xxx
等字段、表示成功升级到 WebSocket 协议。另外,建立 WebSocket 连接之后,通过心跳机制来保持 WebSocket 连接的稳定性和活跃性。
\n简单邮件传输(发送)协议(SMTP,Simple Mail Transfer Protocol) 基于 TCP 协议,是一种用于发送电子邮件的协议
\n\n注意 ⚠️:接受邮件的协议不是 SMTP 而是 POP3 协议。
\nSMTP 协议这块涉及的内容比较多,下面这两个问题比较重要:
\n电子邮件的发送过程?
\n比如我的邮箱是“dabai@cszhinan.com”,我要向“xiaoma@qq.com”发送邮件,整个过程可以简单分为下面几步:
\n如何判断邮箱是真正存在的?
\n很多场景(比如邮件营销)下面我们需要判断我们要发送的邮箱地址是否真的存在,这个时候我们可以利用 SMTP 协议来检测:
\n推荐几个在线邮箱是否有效检测工具:
\n\n这两个协议没必要多做阐述,只需要了解 POP3 和 IMAP 两者都是负责邮件接收的协议 即可(二者也是基于 TCP 协议)。另外,需要注意不要将这两者和 SMTP 协议搞混淆了。SMTP 协议只负责邮件的发送,真正负责接收的协议是 POP3/IMAP。
\nIMAP 协议是比 POP3 更新的协议,它在功能和性能上都更加强大。IMAP 支持邮件搜索、标记、分类、归档等高级功能,而且可以在多个设备之间同步邮件状态。几乎所有现代电子邮件客户端和服务器都支持 IMAP。
\nFTP 协议 基于 TCP 协议,是一种用于在计算机之间传输文件的协议,可以屏蔽操作系统和文件存储方式。
\nFTP 是基于客户—服务器(C/S)模型而设计的,在客户端与 FTP 服务器之间建立两个连接。如果我们要基于 FTP 协议开发一个文件传输的软件的话,首先需要搞清楚 FTP 的原理。关于 FTP 的原理,很多书籍上已经描述的非常详细了:
\n\n\n\nFTP 的独特的优势同时也是与其它客户服务器程序最大的不同点就在于它在两台通信的主机之间使用了两条 TCP 连接(其它客户服务器应用程序一般只有一条 TCP 连接):
\n\n
\n- 控制连接:用于传送控制信息(命令和响应)
\n- 数据连接:用于数据传送;
\n这种将命令和数据分开传送的思想大大提高了 FTP 的效率。
\n
注意 ⚠️:FTP 是一种不安全的协议,因为它在传输过程中不会对数据进行加密。因此,FTP 传输的文件可能会被窃听或篡改。建议在传输敏感数据时使用更安全的协议,如 SFTP(一种基于 SSH 协议的安全文件传输协议,用于在网络上安全地传输文件)。
\nTelnet 协议 基于 TCP 协议,用于通过一个终端登陆到其他服务器。Telnet 协议的最大缺点之一是所有数据(包括用户名和密码)均以明文形式发送,这有潜在的安全风险。这就是为什么如今很少使用 Telnet,而是使用一种称为 SSH 的非常安全的网络传输协议的主要原因。
\n\nSSH(Secure Shell) 基于 TCP 协议,通过加密和认证机制实现安全的访问和文件传输等业务。
\nSSH 的经典用途是登录到远程电脑中执行命令。除此之外,SSH 也支持隧道协议、端口映射和 X11 连接。借助 SFTP 或 SCP 协议,SSH 还可以传输文件。
\nSSH 使用客户端-服务器模型,默认端口是 22。SSH 是一个守护进程,负责实时监听客户端请求,并进行处理。大多数现代操作系统都提供了 SSH。
\n\nRTP(Real-time Transport Protocol,实时传输协议)通常基于 UDP 协议,但也支持 TCP 协议。它提供了端到端的实时传输数据的功能,但不包含资源预留存、不保证实时传输质量,这些功能由 WebRTC 实现。
\nRTP 协议分为两种子协议:
\nDNS(Domain Name System,域名管理系统)基于 UDP 协议,用于解决域名和 IP 地址的映射问题。
\n\nTCP 利用滑动窗口实现流量控制。流量控制是为了控制发送方发送速率,保证接收方来得及接收。 接收方发送的确认报文中的窗口字段可以用来控制发送方窗口大小,从而影响发送方的发送速率。将窗口字段设置为 0,则发送方不能发送数据。
\n为什么需要流量控制? 这是因为双方在通信的时候,发送方的速率与接收方的速率是不一定相等,如果发送方的发送速率太快,会导致接收方处理不过来。如果接收方处理不过来的话,就只能把处理不过来的数据存在 接收缓冲区(Receiving Buffers) 里(失序的数据包也会被存放在缓存区里)。如果缓存区满了发送方还在狂发数据的话,接收方只能把收到的数据包丢掉。出现丢包问题的同时又疯狂浪费着珍贵的网络资源。因此,我们需要控制发送方的发送速率,让接收方与发送方处于一种动态平衡才好。
\n这里需要注意的是(常见误区):
\nTCP 为全双工(Full-Duplex, FDX)通信,双方可以进行双向通信,客户端和服务端既可能是发送端又可能是服务端。因此,两端各有一个发送缓冲区与接收缓冲区,两端都各自维护一个发送窗口和一个接收窗口。接收窗口大小取决于应用、系统、硬件的限制(TCP 传输速率不能大于应用的数据处理速率)。通信双方的发送窗口和接收窗口的要求相同
\nTCP 发送窗口可以划分成四个部分:
\nTCP 发送窗口结构图示:
\n\n可用窗口大小 = SND.UNA + SND.WND - SND.NXT
。
TCP 接收窗口可以划分成三个部分:
\nTCP 接收窗口结构图示:
\n\n接收窗口的大小是根据接收端处理数据的速度动态调整的。 如果接收端读取数据快,接收窗口可能会扩大。 否则,它可能会缩小。
\n另外,这里的滑动窗口大小只是为了演示使用,实际窗口大小通常会远远大于这个值。
\n在某段时间,若对网络中某一资源的需求超过了该资源所能提供的可用部分,网络的性能就要变坏。这种情况就叫拥塞。拥塞控制就是为了防止过多的数据注入到网络中,这样就可以使网络中的路由器或链路不致过载。拥塞控制所要做的都有一个前提,就是网络能够承受现有的网络负荷。拥塞控制是一个全局性的过程,涉及到所有的主机,所有的路由器,以及与降低网络传输性能有关的所有因素。相反,流量控制往往是点对点通信量的控制,是个端到端的问题。流量控制所要做到的就是抑制发送端发送数据的速率,以便使接收端来得及接收。
\n\n为了进行拥塞控制,TCP 发送方要维持一个 拥塞窗口(cwnd) 的状态变量。拥塞控制窗口的大小取决于网络的拥塞程度,并且动态变化。发送方让自己的发送窗口取为拥塞窗口和接收方的接受窗口中较小的一个。
\nTCP 的拥塞控制采用了四种算法,即 慢开始、 拥塞避免、快重传 和 快恢复。在网络层也可以使路由器采用适当的分组丢弃策略(如主动队列管理 AQM),以减少网络拥塞的发生。
\n自动重传请求(Automatic Repeat-reQuest,ARQ)是 OSI 模型中数据链路层和传输层的错误纠正协议之一。它通过使用确认和超时这两个机制,在不可靠服务的基础上实现可靠的信息传输。如果发送方在发送后一段时间之内没有收到确认信息(Acknowledgements,就是我们常说的 ACK),它通常会重新发送,直到收到确认或者重试超过一定的次数。
\nARQ 包括停止等待 ARQ 协议和连续 ARQ 协议。
\n停止等待协议是为了实现可靠传输的,它的基本原理就是每发完一个分组就停止发送,等待对方确认(回复 ACK)。如果过了一段时间(超时时间后),还是没有收到 ACK 确认,说明没有发送成功,需要重新发送,直到收到确认后再发下一个分组;
\n在停止等待协议中,若接收方收到重复分组,就丢弃该分组,但同时还要发送确认。
\n1) 无差错情况:
\n发送方发送分组,接收方在规定时间内收到,并且回复确认.发送方再次发送。
\n2) 出现差错情况(超时重传):
\n停止等待协议中超时重传是指只要超过一段时间仍然没有收到确认,就重传前面发送过的分组(认为刚才发送过的分组丢失了)。因此每发送完一个分组需要设置一个超时计时器,其重传时间应比数据在分组传输的平均往返时间更长一些。这种自动重传方式常称为 自动重传请求 ARQ 。另外在停止等待协议中若收到重复分组,就丢弃该分组,但同时还要发送确认。
\n3) 确认丢失和确认迟到
\n连续 ARQ 协议可提高信道利用率。发送方维持一个发送窗口,凡位于发送窗口内的分组可以连续发送出去,而不需要等待对方确认。接收方一般采用累计确认,对按序到达的最后一个分组发送确认,表明到这个分组为止的所有分组都已经正确收到了。
\n当发送方发送数据之后,它启动一个定时器,等待目的端确认收到这个报文段。接收端实体对已成功收到的包发回一个相应的确认信息(ACK)。如果发送端实体在合理的往返时延(RTT)内未收到确认消息,那么对应的数据包就被假设为已丢失并进行重传。
\nRTO 的确定是一个关键问题,因为它直接影响到 TCP 的性能和效率。如果 RTO 设置得太小,会导致不必要的重传,增加网络负担;如果 RTO 设置得太大,会导致数据传输的延迟,降低吞吐量。因此,RTO 应该根据网络的实际状况,动态地进行调整。
\nRTT 的值会随着网络的波动而变化,所以 TCP 不能直接使用 RTT 作为 RTO。为了动态地调整 RTO,TCP 协议采用了一些算法,如加权移动平均(EWMA)算法,Karn 算法,Jacobson 算法等,这些算法都是根据往返时延(RTT)的测量和变化来估计 RTO 的值。
\n本文是我在大二学习计算机网络期间整理, 大部分内容都来自于谢希仁老师的《计算机网络》第七版这本书。为了内容更容易理解,我对之前的整理进行了一波重构,并配上了一些相关的示意图便于理解。
\n\n相关问题:如何评价谢希仁的计算机网络(第七版)? - 知乎 。
\nhttps://labs.ripe.net/Members/fergalc/ixp-traffic-during-stratos-skydive
\nhttp://conexionesmanwman.blogspot.com/
\nhttps://www.itrelease.com/2018/07/advantages-and-disadvantages-of-personal-area-network-pan/
\n下面的内容会介绍计算机网络的五层体系结构:物理层+数据链路层+网络层(网际层)+运输层+应用层。
\n物理层主要做的事情就是 透明地传送比特流。也可以将物理层的主要任务描述为确定与传输媒体的接口的一些特性,即:机械特性(接口所用接线器的一些物理属性如形状和尺寸),电气特性(接口电缆的各条线上出现的电压的范围),功能特性(某条线上出现的某一电平的电压的意义),过程特性(对于不同功能的各种可能事件的出现顺序)。
\n物理层考虑的是怎样才能在连接各种计算机的传输媒体上传输数据比特流,而不是指具体的传输媒体。 现有的计算机网络中的硬件设备和传输媒体的种类非常繁多,而且通信手段也有许多不同的方式。物理层的作用正是尽可能地屏蔽掉这些传输媒体和通信手段的差异,使物理层上面的数据链路层感觉不到这些差异,这样就可以使数据链路层只考虑完成本层的协议和服务,而不必考虑网络的具体传输媒体和通信手段是什么。
\n用户到互联网的宽带接入方法有非对称数字用户线 ADSL(用数字技术对现有的模拟电话线进行改造,而不需要重新布线。ADSL 的快速版本是甚高速数字用户线 VDSL。),光纤同轴混合网 HFC(是在目前覆盖范围很广的有线电视网的基础上开发的一种居民宽带接入网)和 FTTx(即光纤到······)。
\n以下知识点需要重点关注:
\nhttps://www.seobility.net/en/wiki/HTTP_headers
\nHTTP 协议的本质就是一种浏览器与服务器之间约定好的通信格式。HTTP 的原理如下图所示:
\n\nhttps://www.campaignmonitor.com/resources/knowledge-base/what-is-the-code-that-makes-bcc-or-cc-operate-in-an-email/
\n搜索引擎 :搜索引擎(Search Engine)是指根据一定的策略、运用特定的计算机程序从互联网上搜集信息,在对信息进行组织和处理后,为用户提供检索服务,将用户检索相关的信息展示给用户的系统。搜索引擎包括全文索引、目录索引、元搜索引擎、垂直搜索引擎、集合式搜索引擎、门户搜索引擎与免费链接列表等。
\n垂直搜索引擎:垂直搜索引擎是针对某一个行业的专业搜索引擎,是搜索引擎的细分和延伸,是对网页库中的某类专门的信息进行一次整合,定向分字段抽取出需要的数据进行处理后再以某种形式返回给用户。垂直搜索是相对通用搜索引擎的信息量大、查询不准确、深度不够等提出来的新的搜索引擎服务模式,通过针对某一特定领域、某一特定人群或某一特定需求提供的有一定价值的信息和相关服务。其特点就是“专、精、深”,且具有行业色彩,相比较通用搜索引擎的海量信息无序化,垂直搜索引擎则显得更加专注、具体和深入。
\n全文索引 :全文索引技术是目前搜索引擎的关键技术。试想在 1M 大小的文件中搜索一个词,可能需要几秒,在 100M 的文件中可能需要几十秒,如果在更大的文件中搜索那么就需要更大的系统开销,这样的开销是不现实的。所以在这样的矛盾下出现了全文索引技术,有时候有人叫倒排文档技术。
\n目录索引:目录索引( search index/directory),顾名思义就是将网站分门别类地存放在相应的目录中,因此用户在查询信息时,可选择关键词搜索,也可按分类目录逐层查找。
\n以下知识点需要重点关注:
\n为了准确无误地把数据送达目标处,TCP 协议采用了三次握手策略。
\n建立一个 TCP 连接需要“三次握手”,缺一不可:
\n当建立了 3 次握手之后,客户端和服务端就可以传输数据啦!
\n三次握手的目的是建立可靠的通信信道,说到通讯,简单来说就是数据的发送与接收,而三次握手最主要的目的就是双方确认自己与对方的发送与接收是正常的。
\n三次握手就能确认双方收发功能都正常,缺一不可。
\n更详细的解答可以看这个:TCP 为什么是三次握手,而不是两次或四次? - 车小胖的回答 - 知乎 。
\n服务端传回发送端所发送的 ACK 是为了告诉客户端:“我接收到的信息确实就是你所发送的信号了”,这表明从客户端到服务端的通信是正常的。回传 SYN 则是为了建立并确认从服务端到客户端的通信。
\n\n\nSYN 同步序列编号(Synchronize Sequence Numbers) 是 TCP/IP 建立连接时使用的握手信号。在客户机和服务器之间建立正常的 TCP 网络连接时,客户机首先发出一个 SYN 消息,服务器使用 SYN-ACK 应答表示接收到了这个消息,最后客户机再以 ACK(Acknowledgement)消息响应。这样在客户机和服务器之间才能建立起可靠的 TCP 连接,数据才可以在客户机和服务器之间传递。
\n
断开一个 TCP 连接则需要“四次挥手”,缺一不可:
\n只要四次挥手没有结束,客户端和服务端就可以继续传输数据!
\nTCP 是全双工通信,可以双向传输数据。任何一方都可以在数据传送结束后发出连接释放的通知,待对方确认后进入半关闭状态。当另一方也没有数据再发送的时候,则发出连接释放通知,对方确认后就完全关闭了 TCP 连接。
\n举个例子:A 和 B 打电话,通话即将结束后。
\n因为服务器收到客户端断开连接的请求时,可能还有一些数据没有发完,这时先回复 ACK,表示接收到了断开连接的请求。等到数据发完之后再发 FIN,断开服务器到客户端的数据传送。
\n客户端没有收到 ACK 确认,会重新发送 FIN 请求。
\n第四次挥手时,客户端发送给服务器的 ACK 有可能丢失,如果服务端因为某些原因而没有收到 ACK 的话,服务端就会重发 FIN,如果客户端在 2*MSL 的时间内收到了 FIN,就会重新发送 ACK 并再次等待 2MSL,防止 Server 没有收到 ACK 而不断重发 FIN。
\n\n\nMSL(Maximum Segment Lifetime) : 一个片段在网络中最大的存活时间,2MSL 就是一个发送和一个回复所需的最大时间。如果直到 2MSL,Client 都没有再次收到 FIN,那么 Client 推断 ACK 已经被成功接收,则结束 TCP 连接。
\n
《计算机网络(第 7 版)》
\n《图解 HTTP》
\nTCP and UDP Tutorial:https://www.9tut.com/tcp-and-udp-tutorial
\nJMM(Java 内存模型)主要定义了对于一个共享变量,当另一个线程对这个共享变量执行写操作后,这个线程对这个共享变量的可见性。
\n要想理解透彻 JMM(Java 内存模型),我们先要从 CPU 缓存模型和指令重排序 说起!
\n为什么要弄一个 CPU 高速缓存呢? 类比我们开发网站后台系统使用的缓存(比如 Redis)是为了解决程序处理速度和访问常规关系型数据库速度不对等的问题。 CPU 缓存则是为了解决 CPU 处理速度和内存处理速度不对等的问题。
\n我们甚至可以把 内存看作外存的高速缓存,程序运行的时候我们把外存的数据复制到内存,由于内存的处理速度远远高于外存,这样提高了处理速度。
\n总结:CPU Cache 缓存的是内存数据用于解决 CPU 处理速度和内存不匹配的问题,内存缓存的是硬盘数据用于解决硬盘访问速度过慢的问题。
\n为了更好地理解,我画了一个简单的 CPU Cache 示意图如下所示。
\n\n\n\n🐛 修正(参见:issue#1848):对 CPU 缓存模型绘图不严谨的地方进行完善。
\n
现代的 CPU Cache 通常分为三层,分别叫 L1,L2,L3 Cache。有些 CPU 可能还有 L4 Cache,这里不做讨论,并不常见
\nCPU Cache 的工作方式: 先复制一份数据到 CPU Cache 中,当 CPU 需要用到的时候就可以直接从 CPU Cache 中读取数据,当运算完成后,再将运算得到的数据写回 Main Memory 中。但是,这样存在 内存缓存不一致性的问题 !比如我执行一个 i++ 操作的话,如果两个线程同时执行的话,假设两个线程从 CPU Cache 中读取的 i=1,两个线程做了 i++ 运算完之后再写回 Main Memory 之后 i=2,而正确结果应该是 i=3。
\nCPU 为了解决内存缓存不一致性问题可以通过制定缓存一致协议(比如 MESI 协议)或者其他手段来解决。 这个缓存一致性协议指的是在 CPU 高速缓存与主内存交互的时候需要遵守的原则和规范。不同的 CPU 中,使用的缓存一致性协议通常也会有所不同。
\n\n我们的程序运行在操作系统之上,操作系统屏蔽了底层硬件的操作细节,将各种硬件资源虚拟化。于是,操作系统也就同样需要解决内存缓存不一致性问题。
\n操作系统通过 内存模型(Memory Model) 定义一系列规范来解决这个问题。无论是 Windows 系统,还是 Linux 系统,它们都有特定的内存模型。
\n说完了 CPU 缓存模型,我们再来看看另外一个比较重要的概念 指令重排序 。
\n为了提升执行速度/性能,计算机在执行程序代码的时候,会对指令进行重排序。
\n什么是指令重排序? 简单来说就是系统在执行代码的时候并不一定是按照你写的代码的顺序依次执行。
\n常见的指令重排序有下面 2 种情况:
\n另外,内存系统也会有“重排序”,但又不是真正意义上的重排序。在 JMM 里表现为主存和本地内存的内容可能不一致,进而导致程序在多线程下执行可能出现问题。
\nJava 源代码会经历 编译器优化重排 —> 指令并行重排 —> 内存系统重排 的过程,最终才变成操作系统可执行的指令序列。
\n指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。
\n编译器和处理器的指令重排序的处理方式不一样。对于编译器,通过禁止特定类型的编译器重排序的方式来禁止重排序。对于处理器,通过插入内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)的方式来禁止特定类型的处理器重排序。指令并行重排和内存系统重排都属于是处理器级别的指令重排序。
\n\n\n内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种 CPU 指令,用来禁止处理器指令发生重排序(像屏障一样),从而保障指令执行的有序性。另外,为了达到屏障的效果,它也会使处理器写入、读取值之前,将主内存的值写入高速缓存,清空无效队列,从而保障变量的可见性。
\n
Java 是最早尝试提供内存模型的编程语言。由于早期内存模型存在一些缺陷(比如非常容易削弱编译器的优化能力),从 Java5 开始,Java 开始使用新的内存模型 《JSR-133:Java Memory Model and Thread Specification》 。
\n一般来说,编程语言也可以直接复用操作系统层面的内存模型。不过,不同的操作系统内存模型不同。如果直接复用操作系统层面的内存模型,就可能会导致同样一套代码换了一个操作系统就无法执行了。Java 语言是跨平台的,它需要自己提供一套内存模型以屏蔽系统差异。
\n这只是 JMM 存在的其中一个原因。实际上,对于 Java 来说,你可以把 JMM 看作是 Java 定义的并发编程相关的一组规范,除了抽象了线程和主内存之间的关系之外,其还规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的。
\n为什么要遵守这些并发相关的原则和规范呢? 这是因为并发编程下,像 CPU 多级缓存和指令重排这类设计可能会导致程序运行出现一些问题。就比如说我们上面提到的指令重排序就可能会让多线程程序的执行出现问题,为此,JMM 抽象了 happens-before 原则(后文会详细介绍到)来解决这个指令重排序问题。
\nJMM 说白了就是定义了一些规范来解决这些问题,开发者可以利用这些规范更方便地开发多线程程序。对于 Java 开发者说,你不需要了解底层原理,直接使用并发相关的一些关键字和类(比如 volatile
、synchronized
、各种 Lock
)即可开发出并发安全的程序。
Java 内存模型(JMM) 抽象了线程和主内存之间的关系,就比如说线程之间的共享变量必须存储在主内存中。
\n在 JDK1.2 之前,Java 的内存模型实现总是从 主存 (即共享内存)读取变量,是不需要进行特别的注意的。而在当前的 Java 内存模型下,线程可以把变量保存 本地内存 (比如机器的寄存器)中,而不是直接在主存中进行读写。这就可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致。
\n这和我们上面讲到的 CPU 缓存模型非常相似。
\n什么是主内存?什么是本地内存?
\nJava 内存模型的抽象示意图如下:
\n\n从上图来看,线程 1 与线程 2 之间如果要进行通信的话,必须要经历下面 2 个步骤:
\n也就是说,JMM 为共享变量提供了可见性的保障。
\n不过,多线程下,对主内存中的一个共享变量进行操作有可能诱发线程安全问题。举个例子:
\n关于主内存与工作内存直接的具体交互协议,即一个变量如何从主内存拷贝到工作内存,如何从工作内存同步到主内存之间的实现细节,Java 内存模型定义来以下八种同步操作(了解即可,无需死记硬背):
\n除了这 8 种同步操作之外,还规定了下面这些同步规则来保证这些同步操作的正确执行(了解即可,无需死记硬背):
\n这是一个比较常见的问题,很多初学者非常容易搞混。 Java 内存区域和内存模型是完全不一样的两个东西:
\nhappens-before 这个概念最早诞生于 Leslie Lamport 于 1978 年发表的论文《Time,Clocks and the Ordering of Events in a Distributed System》。在这篇论文中,Leslie Lamport 提出了逻辑时钟的概念,这也成了第一个逻辑时钟算法 。在分布式环境中,通过一系列规则来定义逻辑时钟的变化,从而能通过逻辑时钟来对分布式系统中的事件的先后顺序进行判断。逻辑时钟并不度量时间本身,仅区分事件发生的前后顺序,其本质就是定义了一种 happens-before 关系。
\n上面提到的 happens-before 这个概念诞生的背景并不是重点,简单了解即可。
\nJSR 133 引入了 happens-before 这个概念来描述两个操作之间的内存可见性。
\n为什么需要 happens-before 原则? happens-before 原则的诞生是为了程序员和编译器、处理器之间的平衡。程序员追求的是易于理解和编程的强内存模型,遵守既定规则编码即可。编译器和处理器追求的是较少约束的弱内存模型,让它们尽己所能地去优化性能,让性能最大化。happens-before 原则的设计思想其实非常简单:
\n下面这张是 《Java 并发编程的艺术》这本书中的一张 JMM 设计思想的示意图,非常清晰。
\n\n了解了 happens-before 原则的设计思想,我们再来看看 JSR-133 对 happens-before 原则的定义:
\n我们看下面这段代码:
\nint userNum = getUserNum(); // 1\nint teacherNum = getTeacherNum(); // 2\nint totalNum = userNum + teacherNum; // 3\n
虽然 1 happens-before 2,但对 1 和 2 进行重排序不会影响代码的执行结果,所以 JMM 是允许编译器和处理器执行这种重排序的。但 1 和 2 必须是在 3 执行之前,也就是说 1,2 happens-before 3 。
\nhappens-before 原则表达的意义其实并不是一个操作发生在另外一个操作的前面,虽然这从程序员的角度上来说也并无大碍。更准确地来说,它更想表达的意义是前一个操作的结果对于后一个操作是可见的,无论这两个操作是否在同一个线程里。
\n举个例子:操作 1 happens-before 操作 2,即使操作 1 和操作 2 不在同一个线程内,JMM 也会保证操作 1 的结果对操作 2 是可见的。
\nhappens-before 的规则就 8 条,说多不多,重点了解下面列举的 5 条即可。全记是不可能的,很快就忘记了,意义不大,随时查阅即可。
\nstart()
方法 happens-before 于此线程的每一个动作。如果两个操作不满足上述任意一个 happens-before 规则,那么这两个操作就没有顺序的保障,JVM 可以对这两个操作进行重排序。
\nhappens-before 与 JMM 的关系用《Java 并发编程的艺术》这本书中的一张图就可以非常好的解释清楚。
\n\n一次操作或者多次操作,要么所有的操作全部都得到执行并且不会受到任何因素的干扰而中断,要么都不执行。
\n在 Java 中,可以借助synchronized
、各种 Lock
以及各种原子类实现原子性。
synchronized
和各种 Lock
可以保证任一时刻只有一个线程访问该代码块,因此可以保障原子性。各种原子类是利用 CAS (compare and swap) 操作(可能也会用到 volatile
或者final
关键字)来保证原子操作。
当一个线程对共享变量进行了修改,那么另外的线程都是立即可以看到修改后的最新值。
\n在 Java 中,可以借助synchronized
、volatile
以及各种 Lock
实现可见性。
如果我们将变量声明为 volatile
,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。
由于指令重排序问题,代码的执行顺序未必就是编写代码时候的顺序。
\n我们上面讲重排序的时候也提到过:
\n\n\n指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。
\n
在 Java 中,volatile
关键字可以禁止指令进行重排序优化。
\n\n本篇文章由 JavaGuide 收集自网络,原出处不明。
\n
RabbitMQ 是一个在 AMQP(Advanced Message Queuing Protocol )基础上实现的,可复用的企业消息系统。它可以用于大型软件系统各个模块之间的高效通信,支持高并发,支持可扩展。它支持多种客户端如:Python、Ruby、.NET、Java、JMS、C、PHP、ActionScript、XMPP、STOMP 等,支持 AJAX,持久化,用于在分布式系统中存储转发消息,在易用性、扩展性、高可用性等方面表现不俗。
\nRabbitMQ 是使用 Erlang 编写的一个开源的消息队列,本身支持很多的协议:AMQP,XMPP, SMTP, STOMP,也正是如此,使的它变的非常重量级,更适合于企业级的开发。它同时实现了一个 Broker 构架,这意味着消息在发送给客户端时先在中心队列排队,对路由(Routing)、负载均衡(Load balance)或者数据持久化都有很好的支持。
\nPS:也可能直接问什么是消息队列?消息队列就是一个使用队列来通信的组件。
\nRabbitMQ 整体上是一个生产者与消费者模型,主要负责接收、存储和转发消息。可以把消息传递的过程想象成:当你将一个包裹送到邮局,邮局会暂存并最终将邮件通过邮递员送到收件人的手上,RabbitMQ 就好比由邮局、邮箱和邮递员组成的一个系统。从计算机术语层面来说,RabbitMQ 模型更像是一种交换机模型。
\nRabbitMQ 的整体模型架构如下:
\n\n下面我会一一介绍上图中的一些概念。
\n消息一般由 2 部分组成:消息头(或者说是标签 Label)和 消息体。消息体也可以称为 payLoad ,消息体是不透明的,而消息头则由一系列的可选属性组成,这些属性包括 routing-key(路由键)、priority(相对于其他消息的优先权)、delivery-mode(指出该消息可能需要持久性存储)等。生产者把消息交由 RabbitMQ 后,RabbitMQ 会根据消息头把消息发送给感兴趣的 Consumer(消费者)。
\n在 RabbitMQ 中,消息并不是直接被投递到 Queue(消息队列) 中的,中间还必须经过 Exchange(交换器) 这一层,Exchange(交换器) 会把我们的消息分配到对应的 Queue(消息队列) 中。
\nExchange(交换器) 用来接收生产者发送的消息并将这些消息路由给服务器中的队列中,如果路由不到,或许会返回给 Producer(生产者) ,或许会被直接丢弃掉 。这里可以将 RabbitMQ 中的交换器看作一个简单的实体。
\nRabbitMQ 的 Exchange(交换器) 有 4 种类型,不同的类型对应着不同的路由策略:direct(默认),fanout, topic, 和 headers,不同类型的 Exchange 转发消息的策略有所区别。这个会在介绍 Exchange Types(交换器类型) 的时候介绍到。
\nExchange(交换器) 示意图如下:
\n\n生产者将消息发给交换器的时候,一般会指定一个 RoutingKey(路由键),用来指定这个消息的路由规则,而这个 RoutingKey 需要与交换器类型和绑定键(BindingKey)联合使用才能最终生效。
\nRabbitMQ 中通过 Binding(绑定) 将 Exchange(交换器) 与 Queue(消息队列) 关联起来,在绑定的时候一般会指定一个 BindingKey(绑定建) ,这样 RabbitMQ 就知道如何正确将消息路由到队列了,如下图所示。一个绑定就是基于路由键将交换器和消息队列连接起来的路由规则,所以可以将交换器理解成一个由绑定构成的路由表。Exchange 和 Queue 的绑定可以是多对多的关系。
\nBinding(绑定) 示意图:
\n\n生产者将消息发送给交换器时,需要一个 RoutingKey,当 BindingKey 和 RoutingKey 相匹配时,消息会被路由到对应的队列中。在绑定多个队列到同一个交换器的时候,这些绑定允许使用相同的 BindingKey。BindingKey 并不是在所有的情况下都生效,它依赖于交换器类型,比如 fanout 类型的交换器就会无视,而是将消息路由到所有绑定到该交换器的队列中。
\nQueue(消息队列) 用来保存消息直到发送给消费者。它是消息的容器,也是消息的终点。一个消息可投入一个或多个队列。消息一直在队列里面,等待消费者连接到这个队列将其取走。
\nRabbitMQ 中消息只能存储在 队列 中,这一点和 Kafka 这种消息中间件相反。Kafka 将消息存储在 topic(主题) 这个逻辑层面,而相对应的队列逻辑只是 topic 实际存储文件中的位移标识。 RabbitMQ 的生产者生产消息并最终投递到队列中,消费者可以从队列中获取消息并消费。
\n多个消费者可以订阅同一个队列,这时队列中的消息会被平均分摊(Round-Robin,即轮询)给多个消费者进行处理,而不是每个消费者都收到所有的消息并处理,这样避免消息被重复消费。
\nRabbitMQ 不支持队列层面的广播消费,如果有广播消费的需求,需要在其上进行二次开发,这样会很麻烦,不建议这样做。
\n对于 RabbitMQ 来说,一个 RabbitMQ Broker 可以简单地看作一个 RabbitMQ 服务节点,或者 RabbitMQ 服务实例。大多数情况下也可以将一个 RabbitMQ Broker 看作一台 RabbitMQ 服务器。
\n下图展示了生产者将消息存入 RabbitMQ Broker,以及消费者从 Broker 中消费数据的整个流程。
\n\n这样图 1 中的一些关于 RabbitMQ 的基本概念我们就介绍完毕了,下面再来介绍一下 Exchange Types(交换器类型) 。
\nRabbitMQ 常用的 Exchange Type 有 fanout、direct、topic、headers 这四种(AMQP 规范里还提到两种 Exchange Type,分别为 system 与 自定义,这里不予以描述)。
\n1、fanout
\nfanout 类型的 Exchange 路由规则非常简单,它会把所有发送到该 Exchange 的消息路由到所有与它绑定的 Queue 中,不需要做任何判断操作,所以 fanout 类型是所有的交换机类型里面速度最快的。fanout 类型常用来广播消息。
\n2、direct
\ndirect 类型的 Exchange 路由规则也很简单,它会把消息路由到那些 Bindingkey 与 RoutingKey 完全匹配的 Queue 中。
\n\n以上图为例,如果发送消息的时候设置路由键为“warning”,那么消息会路由到 Queue1 和 Queue2。如果在发送消息的时候设置路由键为\"Info”或者\"debug”,消息只会路由到 Queue2。如果以其他的路由键发送消息,则消息不会路由到这两个队列中。
\ndirect 类型常用在处理有优先级的任务,根据任务的优先级把消息发送到对应的队列,这样可以指派更多的资源去处理高优先级的队列。
\n3、topic
\n前面讲到 direct 类型的交换器路由规则是完全匹配 BindingKey 和 RoutingKey ,但是这种严格的匹配方式在很多情况下不能满足实际业务的需求。topic 类型的交换器在匹配规则上进行了扩展,它与 direct 类型的交换器相似,也是将消息路由到 BindingKey 和 RoutingKey 相匹配的队列中,但这里的匹配规则有些不同,它约定:
\n以上图为例:
\n4、headers(不推荐)
\nheaders 类型的交换器不依赖于路由键的匹配规则来路由消息,而是根据发送的消息内容中的 headers 属性进行匹配。在绑定队列和交换器时指定一组键值对,当发送消息到交换器时,RabbitMQ 会获取到该消息的 headers(也是一个键值对的形式),对比其中的键值对是否完全匹配队列和交换器绑定时指定的键值对,如果完全匹配则消息会路由到该队列,否则不会路由到该队列。headers 类型的交换器性能会很差,而且也不实用,基本上不会看到它的存在。
\nRabbitMQ 就是 AMQP 协议的 Erlang
的实现(当然 RabbitMQ 还支持 STOMP2
、 MQTT3
等协议 ) AMQP 的模型架构 和 RabbitMQ 的模型架构是一样的,生产者将消息发送给交换器,交换器和队列绑定 。
RabbitMQ 中的交换器、交换器类型、队列、绑定、路由键等都是遵循的 AMQP 协议中相 应的概念。目前 RabbitMQ 最新版本默认支持的是 AMQP 0-9-1。
\nAMQP 协议的三层:
\nAMQP 模型的三大组件:
\n生产者 :
\npayload
)和标签(Label
)。消费者:
\nDLX,全称为 Dead-Letter-Exchange
,死信交换器,死信邮箱。当消息在一个队列中变成死信 (dead message
) 之后,它能被重新被发送到另一个交换器中,这个交换器就是 DLX,绑定 DLX 的队列就称之为死信队列。
导致的死信的几种原因:
\nBasic.Reject /Basic.Nack
) 且 requeue = false
。延迟队列指的是存储对应的延迟消息,消息被发送以后,并不想让消费者立刻拿到消息,而是等待特定时间后,消费者才能拿到这个消息进行消费。
\nRabbitMQ 本身是没有延迟队列的,要实现延迟消息,一般有两种方式:
\n也就是说,AMQP 协议以及 RabbitMQ 本身没有直接支持延迟队列的功能,但是可以通过 TTL 和 DLX 模拟出延迟队列的功能。
\nRabbitMQ 自 V3.5.0 有优先级队列实现,优先级高的队列会先被消费。
\n可以通过x-max-priority
参数来实现优先级队列。不过,当消费速度大于生产速度且 Broker 没有堆积的情况下,优先级显得没有意义。
由于 TCP 链接的创建和销毁开销较大,且并发数受系统资源限制,会造成性能瓶颈,所以 RabbitMQ 使用信道的方式来传输数据。信道(Channel)是生产者、消费者与 RabbitMQ 通信的渠道,信道是建立在 TCP 链接上的虚拟链接,且每条 TCP 链接上的信道数量没有限制。就是说 RabbitMQ 在一条 TCP 链接上建立成百上千个信道来达到多个线程处理,这个 TCP 被多个线程共享,每个信道在 RabbitMQ 都有唯一的 ID,保证了信道私有性,每个信道对应一个线程使用。
\n消息到 MQ 的过程中搞丢,MQ 自己搞丢,MQ 到消费过程中搞丢。
\nRabbitMQ 是比较有代表性的,因为是基于主从(非分布式)做高可用性的,我们就以 RabbitMQ 为例子讲解第一种 MQ 的高可用性怎么实现。RabbitMQ 有三种模式:单机模式、普通集群模式、镜像集群模式。
\n单机模式
\nDemo 级别的,一般就是你本地启动了玩玩儿的?,没人生产用单机模式。
\n普通集群模式
\n意思就是在多台机器上启动多个 RabbitMQ 实例,每个机器启动一个。你创建的 queue,只会放在一个 RabbitMQ 实例上,但是每个实例都同步 queue 的元数据(元数据可以认为是 queue 的一些配置信息,通过元数据,可以找到 queue 所在实例)。
\n你消费的时候,实际上如果连接到了另外一个实例,那么那个实例会从 queue 所在实例上拉取数据过来。这方案主要是提高吞吐量的,就是说让集群中多个节点来服务某个 queue 的读写操作。
\n镜像集群模式
\n这种模式,才是所谓的 RabbitMQ 的高可用模式。跟普通集群模式不一样的是,在镜像集群模式下,你创建的 queue,无论元数据还是 queue 里的消息都会存在于多个实例上,就是说,每个 RabbitMQ 节点都有这个 queue 的一个完整镜像,包含 queue 的全部数据的意思。然后每次你写消息到 queue 的时候,都会自动把消息同步到多个实例的 queue 上。RabbitMQ 有很好的管理控制台,就是在后台新增一个策略,这个策略是镜像集群模式的策略,指定的时候是可以要求数据同步到所有节点的,也可以要求同步到指定数量的节点,再次创建 queue 的时候,应用这个策略,就会自动将数据同步到其他的节点上去了。
\n这样的好处在于,你任何一个机器宕机了,没事儿,其它机器(节点)还包含了这个 queue 的完整数据,别的 consumer 都可以到其它节点上去消费数据。坏处在于,第一,这个性能开销也太大了吧,消息需要同步到所有机器上,导致网络带宽压力和消耗很重!RabbitMQ 一个 queue 的数据都是放在一个节点里的,镜像集群下,也是每个节点都放这个 queue 的完整数据。
\nRabbtiMQ 是可以设置过期时间的,也就是 TTL。如果消息在 queue 中积压超过一定的时间就会被 RabbitMQ 给清理掉,这个数据就没了。那这就是第二个坑了。这就不是说数据会大量积压在 mq 里,而是大量的数据会直接搞丢。我们可以采取一个方案,就是批量重导,这个我们之前线上也有类似的场景干过。就是大量积压的时候,我们当时就直接丢弃数据了,然后等过了高峰期以后,比如大家一起喝咖啡熬夜到晚上 12 点以后,用户都睡觉了。这个时候我们就开始写程序,将丢失的那批数据,写个临时程序,一点一点的查出来,然后重新灌入 mq 里面去,把白天丢的数据给他补回来。也只能是这样了。假设 1 万个订单积压在 mq 里面,没有处理,其中 1000 个订单都丢了,你只能手动写程序把那 1000 个订单给查出来,手动发到 mq 里去再补一次。
\n\n", "image": "https://oss.javaguide.cn/github/javaguide/rabbitmq/96388546.jpg", "date_published": "2022-08-02T10:06:55.000Z", "date_modified": "2023-09-19T04:03:55.000Z", "authors": [], "tags": [ "高性能" ] }, { "title": "有了 HTTP 协议,为什么还要有 RPC ?", "url": "https://javaguide.cn/distributed-system/rpc/http_rpc.html", "id": "https://javaguide.cn/distributed-system/rpc/http_rpc.html", "summary": " 本文来自小白 debug投稿,原文:https://juejin.cn/post/7121882245605883934 。 我想起了我刚工作的时候,第一次接触 RPC 协议,当时就很懵,我 HTTP 协议用的好好的,为什么还要用 RPC 协议? 于是就到网上去搜。 不少解释显得非常官方,我相信大家在各种平台上也都看到过,解释了又好像没解释,都在用一...", "content_html": "\n\n本文来自小白 debug投稿,原文:https://juejin.cn/post/7121882245605883934 。
\n
我想起了我刚工作的时候,第一次接触 RPC 协议,当时就很懵,我 HTTP 协议用的好好的,为什么还要用 RPC 协议?
\n于是就到网上去搜。
\n不少解释显得非常官方,我相信大家在各种平台上也都看到过,解释了又好像没解释,都在用一个我们不认识的概念去解释另外一个我们不认识的概念,懂的人不需要看,不懂的人看了还是不懂。
\n这种看了,又好像没看的感觉,云里雾里的很难受,我懂。
\n为了避免大家有强烈的审丑疲劳,今天我们来尝试重新换个方式讲一讲。
\n作为一个程序员,假设我们需要在 A 电脑的进程发一段数据到 B 电脑的进程,我们一般会在代码里使用 socket 进行编程。
\n这时候,我们可选项一般也就TCP 和 UDP 二选一。TCP 可靠,UDP 不可靠。 除非是马总这种神级程序员(早期 QQ 大量使用 UDP),否则,只要稍微对可靠性有些要求,普通人一般无脑选 TCP 就对了。
\n类似下面这样。
\nfd = socket(AF_INET,SOCK_STREAM,0);\n
其中SOCK_STREAM
,是指使用字节流传输数据,说白了就是TCP 协议。
在定义了 socket 之后,我们就可以愉快的对这个 socket 进行操作,比如用bind()
绑定 IP 端口,用connect()
发起建连。
在连接建立之后,我们就可以使用send()
发送数据,recv()
接收数据。
光这样一个纯裸的 TCP 连接,就可以做到收发数据了,那是不是就够了?
\n不行,这么用会有问题。
\n八股文常背,TCP 是有三个特点,面向连接、可靠、基于字节流。
\n\n这三个特点真的概括的 非常精辟 ,这个八股文我们没白背。
\n每个特点展开都能聊一篇文章,而今天我们需要关注的是 基于字节流 这一点。
\n字节流可以理解为一个双向的通道里流淌的二进制数据,也就是 01 串 。纯裸 TCP 收发的这些 01 串之间是 没有任何边界 的,你根本不知道到哪个地方才算一条完整消息。
\n\n正因为这个没有任何边界的特点,所以当我们选择使用 TCP 发送 \"夏洛\"和\"特烦恼\" 的时候,接收端收到的就是 \"夏洛特烦恼\" ,这时候接收端没发区分你是想要表达 \"夏洛\"+\"特烦恼\" 还是 \"夏洛特\"+\"烦恼\" 。
\n\n这就是所谓的 粘包问题,之前也写过一篇专门的文章聊过这个问题。
\n说这个的目的是为了告诉大家,纯裸 TCP 是不能直接拿来用的,你需要在这个基础上加入一些 自定义的规则 ,用于区分 消息边界 。
\n于是我们会把每条要发送的数据都包装一下,比如加入 消息头 ,消息头里写清楚一个完整的包长度是多少,根据这个长度可以继续接收数据,截取出来后它们就是我们真正要传输的 消息体 。
\n\n而这里头提到的 消息头 ,还可以放各种东西,比如消息体是否被压缩过和消息体格式之类的,只要上下游都约定好了,互相都认就可以了,这就是所谓的 协议。
\n每个使用 TCP 的项目都可能会定义一套类似这样的协议解析标准,他们可能 有区别,但原理都类似。
\n于是基于 TCP,就衍生了非常多的协议,比如 HTTP 和 RPC。
\n我们回过头来看网络的分层图。
\n\nTCP 是传输层的协议 ,而基于 TCP 造出来的 HTTP 和各类 RPC 协议,它们都只是定义了不同消息格式的 应用层协议 而已。
\nHTTP(Hyper Text Transfer Protocol)协议又叫做 超文本传输协议 。我们用的比较多,平时上网在浏览器上敲个网址就能访问网页,这里用到的就是 HTTP 协议。
\n\n而 RPC(Remote Procedure Call)又叫做 远程过程调用,它本身并不是一个具体的协议,而是一种 调用方式 。
\n举个例子,我们平时调用一个 本地方法 就像下面这样。
\n res = localFunc(req)\n
如果现在这不是个本地方法,而是个远端服务器暴露出来的一个方法remoteFunc
,如果我们还能像调用本地方法那样去调用它,这样就可以屏蔽掉一些网络细节,用起来更方便,岂不美哉?
res = remoteFunc(req)\n
基于这个思路,大佬们造出了非常多款式的 RPC 协议,比如比较有名的gRPC
,thrift
。
值得注意的是,虽然大部分 RPC 协议底层使用 TCP,但实际上 它们不一定非得使用 TCP,改用 UDP 或者 HTTP,其实也可以做到类似的功能。
\n到这里,我们回到文章标题的问题。
\n其实,TCP 是 70 年 代出来的协议,而 HTTP 是 90 年代 才开始流行的。而直接使用裸 TCP 会有问题,可想而知,这中间这么多年有多少自定义的协议,而这里面就有 80 年代 出来的RPC
。
所以我们该问的不是 既然有 HTTP 协议为什么要有 RPC ,而是 为什么有 RPC 还要有 HTTP 协议?
\n现在电脑上装的各种联网软件,比如 xx 管家,xx 卫士,它们都作为客户端(Client) 需要跟服务端(Server) 建立连接收发消息,此时都会用到应用层协议,在这种 Client/Server (C/S) 架构下,它们可以使用自家造的 RPC 协议,因为它只管连自己公司的服务器就 ok 了。
\n但有个软件不同,浏览器(Browser) ,不管是 Chrome 还是 IE,它们不仅要能访问自家公司的服务器(Server) ,还需要访问其他公司的网站服务器,因此它们需要有个统一的标准,不然大家没法交流。于是,HTTP 就是那个时代用于统一 Browser/Server (B/S) 的协议。
\n也就是说在多年以前,HTTP 主要用于 B/S 架构,而 RPC 更多用于 C/S 架构。但现在其实已经没分那么清了,B/S 和 C/S 在慢慢融合。 很多软件同时支持多端,比如某度云盘,既要支持网页版,还要支持手机端和 PC 端,如果通信协议都用 HTTP 的话,那服务器只用同一套就够了。而 RPC 就开始退居幕后,一般用于公司内部集群里,各个微服务之间的通讯。
\n那这么说的话,都用 HTTP 得了,还用什么 RPC?
\n仿佛又回到了文章开头的样子,那这就要从它们之间的区别开始说起。
\n我们来看看 RPC 和 HTTP 区别比较明显的几个点。
\n首先要向某个服务器发起请求,你得先建立连接,而建立连接的前提是,你得知道 IP 地址和端口 。这个找到服务对应的 IP 端口的过程,其实就是 服务发现。
\n在 HTTP 中,你知道服务的域名,就可以通过 DNS 服务 去解析得到它背后的 IP 地址,默认 80 端口。
\n而 RPC 的话,就有些区别,一般会有专门的中间服务去保存服务名和 IP 信息,比如 Consul、Etcd、Nacos、ZooKeeper,甚至是 Redis。想要访问某个服务,就去这些中间服务去获得 IP 和端口信息。由于 DNS 也是服务发现的一种,所以也有基于 DNS 去做服务发现的组件,比如 CoreDNS。
\n可以看出服务发现这一块,两者是有些区别,但不太能分高低。
\n以主流的 HTTP1.1 协议为例,其默认在建立底层 TCP 连接之后会一直保持这个连接(keep alive),之后的请求和响应都会复用这条连接。
\n而 RPC 协议,也跟 HTTP 类似,也是通过建立 TCP 长链接进行数据交互,但不同的地方在于,RPC 协议一般还会再建个 连接池,在请求量大的时候,建立多条连接放在池内,要发数据的时候就从池里取一条连接出来,用完放回去,下次再复用,可以说非常环保。
\n\n由于连接池有利于提升网络请求性能,所以不少编程语言的网络库里都会给 HTTP 加个连接池,比如 Go 就是这么干的。
\n可以看出这一块两者也没太大区别,所以也不是关键。
\n基于 TCP 传输的消息,说到底,无非都是 消息头 Header 和消息体 Body。
\nHeader 是用于标记一些特殊信息,其中最重要的是 消息体长度。
\nBody 则是放我们真正需要传输的内容,而这些内容只能是二进制 01 串,毕竟计算机只认识这玩意。所以 TCP 传字符串和数字都问题不大,因为字符串可以转成编码再变成 01 串,而数字本身也能直接转为二进制。但结构体呢,我们得想个办法将它也转为二进制 01 串,这样的方案现在也有很多现成的,比如 JSON,Protocol Buffers (Protobuf) 。
\n这个将结构体转为二进制数组的过程就叫 序列化 ,反过来将二进制数组复原成结构体的过程叫 反序列化。
\n\n对于主流的 HTTP1.1,虽然它现在叫超文本协议,支持音频视频,但 HTTP 设计 初是用于做网页文本展示的,所以它传的内容以字符串为主。Header 和 Body 都是如此。在 Body 这块,它使用 JSON 来 序列化 结构体数据。
\n我们可以随便截个图直观看下。
\n\n可以看到这里面的内容非常多的冗余,显得非常啰嗦。最明显的,像 Header 里的那些信息,其实如果我们约定好头部的第几位是 Content-Type
,就不需要每次都真的把 Content-Type
这个字段都传过来,类似的情况其实在 Body 的 JSON 结构里也特别明显。
而 RPC,因为它定制化程度更高,可以采用体积更小的 Protobuf 或其他序列化协议去保存结构体数据,同时也不需要像 HTTP 那样考虑各种浏览器行为,比如 302 重定向跳转啥的。因此性能也会更好一些,这也是在公司内部微服务中抛弃 HTTP,选择使用 RPC 的最主要原因。
\n\n\n当然上面说的 HTTP,其实 特指的是现在主流使用的 HTTP1.1,HTTP2
在前者的基础上做了很多改进,所以 性能可能比很多 RPC 协议还要好,甚至连gRPC
底层都直接用的HTTP2
。
那么问题又来了。
\n这个是由于 HTTP2 是 2015 年出来的。那时候很多公司内部的 RPC 协议都已经跑了好些年了,基于历史原因,一般也没必要去换了。
\n通常情况下,我们创建的变量是可以被任何一个线程访问并修改的。如果想实现每一个线程都有自己的专属本地变量该如何解决呢?
\nJDK 中自带的ThreadLocal
类正是为了解决这样的问题。 ThreadLocal
类主要解决的就是让每个线程绑定自己的值,可以将ThreadLocal
类形象的比喻成存放数据的盒子,盒子中可以存储每个线程的私有数据。
如果你创建了一个ThreadLocal
变量,那么访问这个变量的每个线程都会有这个变量的本地副本,这也是ThreadLocal
变量名的由来。他们可以使用 get()
和 set()
方法来获取默认值或将其值更改为当前线程所存的副本的值,从而避免了线程安全问题。
再举个简单的例子:两个人去宝屋收集宝物,这两个共用一个袋子的话肯定会产生争执,但是给他们两个人每个人分配一个袋子的话就不会出现这样的问题。如果把这两个人比作线程的话,那么 ThreadLocal 就是用来避免这两个线程竞争的。
\n相信看了上面的解释,大家已经搞懂 ThreadLocal
类是个什么东西了。下面简单演示一下如何在项目中实际使用 ThreadLocal
。
import java.text.SimpleDateFormat;\nimport java.util.Random;\n\npublic class ThreadLocalExample implements Runnable{\n\n // SimpleDateFormat 不是线程安全的,所以每个线程都要有自己独立的副本\n private static final ThreadLocal<SimpleDateFormat> formatter = ThreadLocal.withInitial(() -> new SimpleDateFormat(\"yyyyMMdd HHmm\"));\n\n public static void main(String[] args) throws InterruptedException {\n ThreadLocalExample obj = new ThreadLocalExample();\n for(int i=0 ; i<10; i++){\n Thread t = new Thread(obj, \"\"+i);\n Thread.sleep(new Random().nextInt(1000));\n t.start();\n }\n }\n\n @Override\n public void run() {\n System.out.println(\"Thread Name= \"+Thread.currentThread().getName()+\" default Formatter = \"+formatter.get().toPattern());\n try {\n Thread.sleep(new Random().nextInt(1000));\n } catch (InterruptedException e) {\n e.printStackTrace();\n }\n //formatter pattern is changed here by thread, but it won't reflect to other threads\n formatter.set(new SimpleDateFormat());\n\n System.out.println(\"Thread Name= \"+Thread.currentThread().getName()+\" formatter = \"+formatter.get().toPattern());\n }\n\n}\n\n
输出结果 :
\nThread Name= 0 default Formatter = yyyyMMdd HHmm\nThread Name= 0 formatter = yy-M-d ah:mm\nThread Name= 1 default Formatter = yyyyMMdd HHmm\nThread Name= 2 default Formatter = yyyyMMdd HHmm\nThread Name= 1 formatter = yy-M-d ah:mm\nThread Name= 3 default Formatter = yyyyMMdd HHmm\nThread Name= 2 formatter = yy-M-d ah:mm\nThread Name= 4 default Formatter = yyyyMMdd HHmm\nThread Name= 3 formatter = yy-M-d ah:mm\nThread Name= 4 formatter = yy-M-d ah:mm\nThread Name= 5 default Formatter = yyyyMMdd HHmm\nThread Name= 5 formatter = yy-M-d ah:mm\nThread Name= 6 default Formatter = yyyyMMdd HHmm\nThread Name= 6 formatter = yy-M-d ah:mm\nThread Name= 7 default Formatter = yyyyMMdd HHmm\nThread Name= 7 formatter = yy-M-d ah:mm\nThread Name= 8 default Formatter = yyyyMMdd HHmm\nThread Name= 9 default Formatter = yyyyMMdd HHmm\nThread Name= 8 formatter = yy-M-d ah:mm\nThread Name= 9 formatter = yy-M-d ah:mm\n
从输出中可以看出,虽然 Thread-0
已经改变了 formatter
的值,但 Thread-1
默认格式化值与初始化值相同,其他线程也一样。
上面有一段代码用到了创建 ThreadLocal
变量的那段代码用到了 Java8 的知识,它等于下面这段代码,如果你写了下面这段代码的话,IDEA 会提示你转换为 Java8 的格式(IDEA 真的不错!)。因为 ThreadLocal 类在 Java 8 中扩展,使用一个新的方法withInitial()
,将 Supplier 功能接口作为参数。
private static final ThreadLocal<SimpleDateFormat> formatter = new ThreadLocal<SimpleDateFormat>(){\n @Override\n protected SimpleDateFormat initialValue(){\n return new SimpleDateFormat(\"yyyyMMdd HHmm\");\n }\n};\n
从 Thread
类源代码入手。
public class Thread implements Runnable {\n //......\n //与此线程有关的ThreadLocal值。由ThreadLocal类维护\n ThreadLocal.ThreadLocalMap threadLocals = null;\n\n //与此线程有关的InheritableThreadLocal值。由InheritableThreadLocal类维护\n ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;\n //......\n}\n
从上面Thread
类 源代码可以看出Thread
类中有一个 threadLocals
和 一个 inheritableThreadLocals
变量,它们都是 ThreadLocalMap
类型的变量,我们可以把 ThreadLocalMap
理解为ThreadLocal
类实现的定制化的 HashMap
。默认情况下这两个变量都是 null,只有当前线程调用 ThreadLocal
类的 set
或get
方法时才创建它们,实际上调用这两个方法的时候,我们调用的是ThreadLocalMap
类对应的 get()
、set()
方法。
ThreadLocal
类的set()
方法
public void set(T value) {\n //获取当前请求的线程\n Thread t = Thread.currentThread();\n //取出 Thread 类内部的 threadLocals 变量(哈希表结构)\n ThreadLocalMap map = getMap(t);\n if (map != null)\n // 将需要存储的值放入到这个哈希表中\n map.set(this, value);\n else\n createMap(t, value);\n}\nThreadLocalMap getMap(Thread t) {\n return t.threadLocals;\n}\n
通过上面这些内容,我们足以通过猜测得出结论:最终的变量是放在了当前线程的 ThreadLocalMap
中,并不是存在 ThreadLocal
上,ThreadLocal
可以理解为只是ThreadLocalMap
的封装,传递了变量值。 ThrealLocal
类中可以通过Thread.currentThread()
获取到当前线程对象后,直接通过getMap(Thread t)
可以访问到该线程的ThreadLocalMap
对象。
每个Thread
中都具备一个ThreadLocalMap
,而ThreadLocalMap
可以存储以ThreadLocal
为 key ,Object 对象为 value 的键值对。
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {\n //......\n}\n
比如我们在同一个线程中声明了两个 ThreadLocal
对象的话, Thread
内部都是使用仅有的那个ThreadLocalMap
存放数据的,ThreadLocalMap
的 key 就是 ThreadLocal
对象,value 就是 ThreadLocal
对象调用set
方法设置的值。
ThreadLocal
数据结构如下图所示:
ThreadLocalMap
是ThreadLocal
的静态内部类。
ThreadLocalMap
中使用的 key 为 ThreadLocal
的弱引用,而 value 是强引用。所以,如果 ThreadLocal
没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。
这样一来,ThreadLocalMap
中就会出现 key 为 null 的 Entry。假如我们不做任何措施的话,value 永远无法被 GC 回收,这个时候就可能会产生内存泄露。ThreadLocalMap
实现中已经考虑了这种情况,在调用 set()
、get()
、remove()
方法的时候,会清理掉 key 为 null 的记录。使用完 ThreadLocal
方法后最好手动调用remove()
方法
static class Entry extends WeakReference<ThreadLocal<?>> {\n /** The value associated with this ThreadLocal. */\n Object value;\n\n Entry(ThreadLocal<?> k, Object v) {\n super(k);\n value = v;\n }\n}\n
弱引用介绍:
\n\n\n如果一个对象只具有弱引用,那就类似于可有可无的生活用品。弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它 所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。
\n弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java 虚拟机就会把这个弱引用加入到与之关联的引用队列中。
\n
顾名思义,线程池就是管理一系列线程的资源池。当有任务要处理时,直接从线程池中获取线程来处理,处理完之后线程并不会立即被销毁,而是等待下一个任务。
\n池化技术想必大家已经屡见不鲜了,线程池、数据库连接池、HTTP 连接池等等都是对这个思想的应用。池化技术的思想主要是为了减少每次获取资源的消耗,提高对资源的利用率。
\n线程池提供了一种限制和管理资源(包括执行一个任务)的方式。 每个线程池还维护一些基本统计信息,例如已完成任务的数量。
\n这里借用《Java 并发编程的艺术》提到的来说一下使用线程池的好处:
\n方式一:通过ThreadPoolExecutor
构造函数来创建(推荐)。
方式二:通过 Executor
框架的工具类 Executors
来创建。
我们可以创建多种类型的 ThreadPoolExecutor
:
FixedThreadPool
:该方法返回一个固定线程数量的线程池。该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。SingleThreadExecutor
: 该方法返回一个只有一个线程的线程池。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。CachedThreadPool
: 该方法返回一个可根据实际情况调整线程数量的线程池。初始大小为 0。当有新任务提交时,如果当前线程池中没有线程可用,它会创建一个新的线程来处理该任务。如果在一段时间内(默认为 60 秒)没有新任务提交,核心线程会超时并被销毁,从而缩小线程池的大小。ScheduledThreadPool
:该方法返回一个用来在给定的延迟后运行任务或者定期执行任务的线程池。对应 Executors
工具类中的方法如图所示:
在《阿里巴巴 Java 开发手册》“并发处理”这一章节,明确指出线程资源必须通过线程池提供,不允许在应用中自行显式创建线程。
\n为什么呢?
\n\n\n使用线程池的好处是减少在创建和销毁线程上所消耗的时间以及系统资源开销,解决资源不足的问题。如果不使用线程池,有可能会造成系统创建大量同类线程而导致消耗完内存或者“过度切换”的问题。
\n
另外,《阿里巴巴 Java 开发手册》中强制线程池不允许使用 Executors
去创建,而是通过 ThreadPoolExecutor
构造函数的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险
Executors
返回线程池对象的弊端如下(后文会详细介绍到):
FixedThreadPool
和 SingleThreadExecutor
:使用的是无界的 LinkedBlockingQueue
,任务队列最大长度为 Integer.MAX_VALUE
,可能堆积大量的请求,从而导致 OOM。CachedThreadPool
:使用的是同步队列 SynchronousQueue
, 允许创建的线程数量为 Integer.MAX_VALUE
,如果任务数量过多且执行速度较慢,可能会创建大量的线程,从而导致 OOM。ScheduledThreadPool
和 SingleThreadScheduledExecutor
: 使用的无界的延迟阻塞队列DelayedWorkQueue
,任务队列最大长度为 Integer.MAX_VALUE
,可能堆积大量的请求,从而导致 OOM。// 无界队列 LinkedBlockingQueue\npublic static ExecutorService newFixedThreadPool(int nThreads) {\n\n return new ThreadPoolExecutor(nThreads, nThreads,0L, TimeUnit.MILLISECONDS,new LinkedBlockingQueue<Runnable>());\n\n}\n\n// 无界队列 LinkedBlockingQueue\npublic static ExecutorService newSingleThreadExecutor() {\n\n return new FinalizableDelegatedExecutorService (new ThreadPoolExecutor(1, 1,0L, TimeUnit.MILLISECONDS,new LinkedBlockingQueue<Runnable>()));\n\n}\n\n// 同步队列 SynchronousQueue,没有容量,最大线程数是 Integer.MAX_VALUE`\npublic static ExecutorService newCachedThreadPool() {\n\n return new ThreadPoolExecutor(0, Integer.MAX_VALUE,60L, TimeUnit.SECONDS,new SynchronousQueue<Runnable>());\n\n}\n\n// DelayedWorkQueue(延迟阻塞队列)\npublic static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {\n return new ScheduledThreadPoolExecutor(corePoolSize);\n}\npublic ScheduledThreadPoolExecutor(int corePoolSize) {\n super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,\n new DelayedWorkQueue());\n}\n
/**\n * 用给定的初始参数创建一个新的ThreadPoolExecutor。\n */\n public ThreadPoolExecutor(int corePoolSize,//线程池的核心线程数量\n int maximumPoolSize,//线程池的最大线程数\n long keepAliveTime,//当线程数大于核心线程数时,多余的空闲线程存活的最长时间\n TimeUnit unit,//时间单位\n BlockingQueue<Runnable> workQueue,//任务队列,用来储存等待执行任务的队列\n ThreadFactory threadFactory,//线程工厂,用来创建线程,一般默认即可\n RejectedExecutionHandler handler//拒绝策略,当提交的任务过多而不能及时处理时,我们可以定制策略来处理任务\n ) {\n if (corePoolSize < 0 ||\n maximumPoolSize <= 0 ||\n maximumPoolSize < corePoolSize ||\n keepAliveTime < 0)\n throw new IllegalArgumentException();\n if (workQueue == null || threadFactory == null || handler == null)\n throw new NullPointerException();\n this.corePoolSize = corePoolSize;\n this.maximumPoolSize = maximumPoolSize;\n this.workQueue = workQueue;\n this.keepAliveTime = unit.toNanos(keepAliveTime);\n this.threadFactory = threadFactory;\n this.handler = handler;\n }\n
ThreadPoolExecutor
3 个最重要的参数:
corePoolSize
: 任务队列未达到队列容量时,最大可以同时运行的线程数量。maximumPoolSize
: 任务队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。workQueue
: 新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。ThreadPoolExecutor
其他常见参数 :
keepAliveTime
:线程池中的线程数量大于 corePoolSize
的时候,如果这时没有新的任务提交,多余的空闲线程不会立即销毁,而是会等待,直到等待的时间超过了 keepAliveTime
才会被回收销毁,线程池回收线程时,会对核心线程和非核心线程一视同仁,直到线程池中线程的数量等于 corePoolSize
,回收过程才会停止。unit
: keepAliveTime
参数的时间单位。threadFactory
:executor 创建新线程的时候会用到。handler
:饱和策略。关于饱和策略下面单独介绍一下。下面这张图可以加深你对线程池中各个参数的相互关系的理解(图片来源:《Java 性能调优实战》):
\n\n如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任务时,ThreadPoolExecutor
定义一些策略:
ThreadPoolExecutor.AbortPolicy
: 抛出 RejectedExecutionException
来拒绝新任务的处理。ThreadPoolExecutor.CallerRunsPolicy
: 调用执行自己的线程运行任务,也就是直接在调用execute
方法的线程中运行(run
)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果您的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。ThreadPoolExecutor.DiscardPolicy
: 不处理新任务,直接丢弃掉。ThreadPoolExecutor.DiscardOldestPolicy
: 此策略将丢弃最早的未处理的任务请求。举个例子:Spring 通过 ThreadPoolTaskExecutor
或者我们直接通过 ThreadPoolExecutor
的构造函数创建线程池的时候,当我们不指定 RejectedExecutionHandler
饱和策略来配置线程池的时候,默认使用的是 AbortPolicy
。在这种饱和策略下,如果队列满了,ThreadPoolExecutor
将抛出 RejectedExecutionException
异常来拒绝新来的任务 ,这代表你将丢失对这个任务的处理。如果不想丢弃任务的话,可以使用CallerRunsPolicy
。CallerRunsPolicy
和其他的几个策略不同,它既不会抛弃任务,也不会抛出异常,而是将任务回退给调用者,使用调用者的线程来执行任务
public static class CallerRunsPolicy implements RejectedExecutionHandler {\n\n public CallerRunsPolicy() { }\n\n public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {\n if (!e.isShutdown()) {\n // 直接主线程执行,而不是线程池中的线程执行\n r.run();\n }\n }\n }\n
新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。
\n不同的线程池会选用不同的阻塞队列,我们可以结合内置线程池来分析。
\nInteger.MAX_VALUE
的 LinkedBlockingQueue
(无界队列):FixedThreadPool
和 SingleThreadExector
。FixedThreadPool
最多只能创建核心线程数的线程(核心线程数和最大线程数相等),SingleThreadExector
只能创建一个线程(核心线程数和最大线程数都是 1),二者的任务队列永远不会被放满。SynchronousQueue
(同步队列):CachedThreadPool
。SynchronousQueue
没有容量,不存储元素,目的是保证对于提交的任务,如果有空闲线程,则使用空闲线程来处理;否则新建一个线程来处理任务。也就是说,CachedThreadPool
的最大线程数是 Integer.MAX_VALUE
,可以理解为线程数是可以无限扩展的,可能会创建大量线程,从而导致 OOM。DelayedWorkQueue
(延迟阻塞队列):ScheduledThreadPool
和 SingleThreadScheduledExecutor
。DelayedWorkQueue
的内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构,可以保证每次出队的任务都是当前队列中执行时间最靠前的。DelayedWorkQueue
添加元素满了之后会自动扩容原来容量的 1/2,即永远不会阻塞,最大扩容可达 Integer.MAX_VALUE
,所以最多只能创建核心线程数的线程。RejectedExecutionHandler.rejectedExecution()
方法。初始化线程池的时候需要显示命名(设置线程池名称前缀),有利于定位问题。
\n默认情况下创建的线程名字类似 pool-1-thread-n
这样的,没有业务含义,不利于我们定位问题。
给线程池里的线程命名通常有下面两种方式:
\n1、利用 guava 的 ThreadFactoryBuilder
ThreadFactory threadFactory = new ThreadFactoryBuilder()\n .setNameFormat(threadNamePrefix + \"-%d\")\n .setDaemon(true).build();\nExecutorService threadPool = new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, TimeUnit.MINUTES, workQueue, threadFactory);\n
2、自己实现 ThreadFactory
。
import java.util.concurrent.ThreadFactory;\nimport java.util.concurrent.atomic.AtomicInteger;\n\n/**\n * 线程工厂,它设置线程名称,有利于我们定位问题。\n */\npublic final class NamingThreadFactory implements ThreadFactory {\n\n private final AtomicInteger threadNum = new AtomicInteger();\n private final String name;\n\n /**\n * 创建一个带名字的线程池生产工厂\n */\n public NamingThreadFactory(String name) {\n this.name = name;\n }\n\n @Override\n public Thread newThread(Runnable r) {\n Thread t = new Thread(r);\n t.setName(name + \" [#\" + threadNum.incrementAndGet() + \"]\");\n return t;\n }\n}\n
很多人甚至可能都会觉得把线程池配置过大一点比较好!我觉得这明显是有问题的。就拿我们生活中非常常见的一例子来说:并不是人多就能把事情做好,增加了沟通交流成本。你本来一件事情只需要 3 个人做,你硬是拉来了 6 个人,会提升做事效率嘛?我想并不会。 线程数量过多的影响也是和我们分配多少人做事情一样,对于多线程这个场景来说主要是增加了上下文切换成本。不清楚什么是上下文切换的话,可以看我下面的介绍。
\n\n\n上下文切换:
\n多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。概括来说就是:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换。
\n上下文切换通常是计算密集型的。也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。
\nLinux 相比与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有一项就是,其上下文切换和模式切换的时间消耗非常少。
\n
类比于实现世界中的人类通过合作做某件事情,我们可以肯定的一点是线程池大小设置过大或者过小都会有问题,合适的才是最好。
\n有一个简单并且适用面比较广的公式:
\n如何判断是 CPU 密集任务还是 IO 密集任务?
\nCPU 密集型简单理解就是利用 CPU 计算能力的任务比如你在内存中对大量数据进行排序。但凡涉及到网络读取,文件读取这类都是 IO 密集型,这类任务的特点是 CPU 计算耗费时间相比于等待 IO 操作完成的时间来说很少,大部分时间都花在了等待 IO 操作完成上。
\n\n\n🌈 拓展一下(参见:issue#1737):
\n线程数更严谨的计算的方法应该是:
\n最佳线程数 = N(CPU 核心数)∗(1+WT(线程等待时间)/ST(线程计算时间))
,其中WT(线程等待时间)=线程运行总时间 - ST(线程计算时间)
。线程等待时间所占比例越高,需要越多线程。线程计算时间所占比例越高,需要越少线程。
\n我们可以通过 JDK 自带的工具 VisualVM 来查看
\nWT/ST
比例。CPU 密集型任务的
\nWT/ST
接近或者等于 0,因此, 线程数可以设置为 N(CPU 核心数)∗(1+0)= N,和我们上面说的 N(CPU 核心数)+1 差不多。IO 密集型任务下,几乎全是线程等待时间,从理论上来说,你就可以将线程数设置为 2N(按道理来说,WT/ST 的结果应该比较大,这里选择 2N 的原因应该是为了避免创建过多线程吧)。
\n
公示也只是参考,具体还是要根据项目实际线上运行情况来动态调整。我在后面介绍的美团的线程池参数动态配置这种方案就非常不错,很实用!
\n美团技术团队在《Java 线程池实现原理及其在美团业务中的实践》这篇文章中介绍到对线程池参数实现可自定义配置的思路和方法。
\n美团技术团队的思路是主要对线程池的核心参数实现自定义可配置。这三个核心参数是:
\ncorePoolSize
: 核心线程数线程数定义了最小可以同时运行的线程数量。maximumPoolSize
: 当队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。workQueue
: 当新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。为什么是这三个参数?
\n我在Java 线程池详解 这篇文章中就说过这三个参数是 ThreadPoolExecutor
最重要的参数,它们基本决定了线程池对于任务的处理策略。
如何支持参数动态配置? 且看 ThreadPoolExecutor
提供的下面这些方法。
格外需要注意的是corePoolSize
, 程序运行期间的时候,我们调用 setCorePoolSize()
这个方法的话,线程池会首先判断当前工作线程数是否大于corePoolSize
,如果大于的话就会回收工作线程。
另外,你也看到了上面并没有动态指定队列长度的方法,美团的方式是自定义了一个叫做 ResizableCapacityLinkedBlockIngQueue
的队列(主要就是把LinkedBlockingQueue
的 capacity 字段的 final 关键字修饰给去掉了,让它变为可变的)。
最终实现的可动态修改线程池参数效果如下。👏👏👏
\n\n还没看够?推荐 why 神的如何设置线程池参数?美团给出了一个让面试官虎躯一震的回答。这篇文章,深度剖析,很不错哦!
\n如果我们的项目也想要实现这种效果的话,可以借助现成的开源项目:
\n这是一个常见的面试问题,本质其实还是在考察求职者对于线程池以及阻塞队列的掌握。
\n我们上面也提到了,不同的线程池会选用不同的阻塞队列作为任务队列,比如FixedThreadPool
使用的是LinkedBlockingQueue
(无界队列),由于队列永远不会被放满,因此FixedThreadPool
最多只能创建核心线程数的线程。
假如我们需要实现一个优先级任务线程池的话,那可以考虑使用 PriorityBlockingQueue
(优先级阻塞队列)作为任务队列(ThreadPoolExecutor
的构造函数有一个 workQueue
参数可以传入任务队列)。
PriorityBlockingQueue
是一个支持优先级的无界阻塞队列,可以看作是线程安全的 PriorityQueue
,两者底层都是使用小顶堆形式的二叉堆,即值最小的元素优先出队。不过,PriorityQueue
不支持阻塞操作。
要想让 PriorityBlockingQueue
实现对任务的排序,传入其中的任务必须是具备排序能力的,方式有两种:
Comparable
接口,并重写 compareTo
方法来指定任务之间的优先级比较规则。PriorityBlockingQueue
时传入一个 Comparator
对象来指定任务之间的排序规则(推荐)。不过,这存在一些风险和问题,比如:
\nPriorityBlockingQueue
是无界的,可能堆积大量的请求,从而导致 OOM。ReentrantLock
),因此会降低性能。对于 OOM 这个问题的解决比较简单粗暴,就是继承PriorityBlockingQueue
并重写一下 offer
方法(入队)的逻辑,当插入的元素数量超过指定值就返回 false 。
饥饿问题这个可以通过优化设计来解决(比较麻烦),比如等待时间过长的任务会被移除并重新添加到队列中,但是优先级会被提升。
\n对于性能方面的影响,是没办法避免的,毕竟需要对任务进行排序操作。并且,对于大部分业务场景来说,这点性能影响是可以接受的。
\nFuture
类是异步思想的典型运用,主要用在一些需要执行耗时任务的场景,避免程序一直原地等待耗时任务执行完成,执行效率太低。具体来说是这样的:当我们执行某一耗时的任务时,可以将这个耗时任务交给一个子线程去异步执行,同时我们可以干点其他事情,不用傻傻等待耗时任务执行完成。等我们的事情干完后,我们再通过 Future
类获取到耗时任务的执行结果。这样一来,程序的执行效率就明显提高了。
这其实就是多线程中经典的 Future 模式,你可以将其看作是一种设计模式,核心思想是异步调用,主要用在多线程领域,并非 Java 语言独有。
\n在 Java 中,Future
类只是一个泛型接口,位于 java.util.concurrent
包下,其中定义了 5 个方法,主要包括下面这 4 个功能:
// V 代表了Future执行的任务返回值的类型\npublic interface Future<V> {\n // 取消任务执行\n // 成功取消返回 true,否则返回 false\n boolean cancel(boolean mayInterruptIfRunning);\n // 判断任务是否被取消\n boolean isCancelled();\n // 判断任务是否已经执行完成\n boolean isDone();\n // 获取任务执行结果\n V get() throws InterruptedException, ExecutionException;\n // 指定时间内没有返回计算结果就抛出 TimeOutException 异常\n V get(long timeout, TimeUnit unit)\n\n throws InterruptedException, ExecutionException, TimeoutExceptio\n\n}\n
简单理解就是:我有一个任务,提交给了 Future
来处理。任务执行期间我自己可以去做任何想做的事情。并且,在这期间我还可以取消任务以及获取任务的执行状态。一段时间之后,我就可以 Future
那里直接取出任务执行结果。
我们可以通过 FutureTask
来理解 Callable
和 Future
之间的关系。
FutureTask
提供了 Future
接口的基本实现,常用来封装 Callable
和 Runnable
,具有取消任务、查看任务是否执行完成以及获取任务执行结果的方法。ExecutorService.submit()
方法返回的其实就是 Future
的实现类 FutureTask
。
<T> Future<T> submit(Callable<T> task);\nFuture<?> submit(Runnable task);\n
FutureTask
不光实现了 Future
接口,还实现了Runnable
接口,因此可以作为任务直接被线程执行。
FutureTask
有两个构造函数,可传入 Callable
或者 Runnable
对象。实际上,传入 Runnable
对象也会在方法内部转换为Callable
对象。
public FutureTask(Callable<V> callable) {\n if (callable == null)\n throw new NullPointerException();\n this.callable = callable;\n this.state = NEW;\n}\npublic FutureTask(Runnable runnable, V result) {\n // 通过适配器RunnableAdapter来将Runnable对象runnable转换成Callable对象\n this.callable = Executors.callable(runnable, result);\n this.state = NEW;\n}\n
FutureTask
相当于对Callable
进行了封装,管理着任务执行的情况,存储了 Callable
的 call
方法的任务执行结果。
Future
在实际使用过程中存在一些局限性比如不支持异步任务的编排组合、获取计算结果的 get()
方法为阻塞调用。
Java 8 才被引入CompletableFuture
类可以解决Future
的这些缺陷。CompletableFuture
除了提供了更为好用和强大的 Future
特性之外,还提供了函数式编程、异步任务编排组合(可以将多个异步任务串联起来,组成一个完整的链式调用)等能力。
下面我们来简单看看 CompletableFuture
类的定义。
public class CompletableFuture<T> implements Future<T>, CompletionStage<T> {\n}\n
可以看到,CompletableFuture
同时实现了 Future
和 CompletionStage
接口。
CompletionStage
接口描述了一个异步计算的阶段。很多计算可以分成多个阶段或步骤,此时可以通过它将所有步骤组合起来,形成异步计算的流水线。
CompletionStage
接口中的方法比较多,CompletableFuture
的函数式能力就是这个接口赋予的。从这个接口的方法参数你就可以发现其大量使用了 Java8 引入的函数式编程。
AQS 的全称为 AbstractQueuedSynchronizer
,翻译过来的意思就是抽象队列同步器。这个类在 java.util.concurrent.locks
包下面。
AQS 就是一个抽象类,主要用来构建锁和同步器。
\npublic abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer implements java.io.Serializable {\n}\n
AQS 为构建锁和同步器提供了一些通用功能的实现,因此,使用 AQS 能简单且高效地构造出应用广泛的大量的同步器,比如我们提到的 ReentrantLock
,Semaphore
,其他的诸如 ReentrantReadWriteLock
,SynchronousQueue
等等皆是基于 AQS 的。
AQS 核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是用 CLH 队列锁 实现的,即将暂时获取不到锁的线程加入到队列中。
\nCLH(Craig,Landin,and Hagersten) 队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS 是将每条请求共享资源的线程封装成一个 CLH 锁队列的一个结点(Node)来实现锁的分配。在 CLH 同步队列中,一个节点表示一个线程,它保存着线程的引用(thread)、 当前节点在队列中的状态(waitStatus)、前驱节点(prev)、后继节点(next)。
\nCLH 队列结构如下图所示:
\n\nAQS(AbstractQueuedSynchronizer
)的核心原理图(图源Java 并发之 AQS 详解)如下:
AQS 使用 int 成员变量 state
表示同步状态,通过内置的 线程等待队列 来完成获取资源线程的排队工作。
state
变量由 volatile
修饰,用于展示当前临界资源的获锁情况。
// 共享变量,使用volatile修饰保证线程可见性\nprivate volatile int state;\n
另外,状态信息 state
可以通过 protected
类型的getState()
、setState()
和compareAndSetState()
进行操作。并且,这几个方法都是 final
修饰的,在子类中无法被重写。
//返回同步状态的当前值\nprotected final int getState() {\n return state;\n}\n // 设置同步状态的值\nprotected final void setState(int newState) {\n state = newState;\n}\n//原子地(CAS操作)将同步状态值设置为给定值update如果当前同步状态的值等于expect(期望值)\nprotected final boolean compareAndSetState(int expect, int update) {\n return unsafe.compareAndSwapInt(this, stateOffset, expect, update);\n}\n
以 ReentrantLock
为例,state
初始值为 0,表示未锁定状态。A 线程 lock()
时,会调用 tryAcquire()
独占该锁并将 state+1
。此后,其他线程再 tryAcquire()
时就会失败,直到 A 线程 unlock()
到 state=
0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A 线程自己是可以重复获取此锁的(state
会累加),这就是可重入的概念。但要注意,获取多少次就要释放多少次,这样才能保证 state 是能回到零态的。
再以 CountDownLatch
以例,任务分为 N 个子线程去执行,state
也初始化为 N(注意 N 要与线程个数一致)。这 N 个子线程是并行执行的,每个子线程执行完后countDown()
一次,state 会 CAS(Compare and Swap) 减 1。等到所有子线程都执行完后(即 state=0
),会 unpark()
主调用线程,然后主调用线程就会从 await()
函数返回,继续后余动作。
synchronized
和 ReentrantLock
都是一次只允许一个线程访问某个资源,而Semaphore
(信号量)可以用来控制同时访问特定资源的线程数量。
Semaphore 的使用简单,我们这里假设有 N(N>5) 个线程来获取 Semaphore
中的共享资源,下面的代码表示同一时刻 N 个线程中只有 5 个线程能获取到共享资源,其他线程都会阻塞,只有获取到共享资源的线程才能执行。等到有线程释放了共享资源,其他阻塞的线程才能获取到。
// 初始共享资源数量\nfinal Semaphore semaphore = new Semaphore(5);\n// 获取1个许可\nsemaphore.acquire();\n// 释放1个许可\nsemaphore.release();\n
当初始的资源个数为 1 的时候,Semaphore
退化为排他锁。
Semaphore
有两种模式:。
acquire()
方法的顺序就是获取许可证的顺序,遵循 FIFO;Semaphore
对应的两个构造方法如下:
public Semaphore(int permits) {\n sync = new NonfairSync(permits);\n}\n\npublic Semaphore(int permits, boolean fair) {\n sync = fair ? new FairSync(permits) : new NonfairSync(permits);\n}\n
这两个构造方法,都必须提供许可的数量,第二个构造方法可以指定是公平模式还是非公平模式,默认非公平模式。
\nSemaphore
通常用于那些资源有明确访问数量限制的场景比如限流(仅限于单机模式,实际项目中推荐使用 Redis +Lua 来做限流)。
Semaphore
是共享锁的一种实现,它默认构造 AQS 的 state
值为 permits
,你可以将 permits
的值理解为许可证的数量,只有拿到许可证的线程才能执行。
调用semaphore.acquire()
,线程尝试获取许可证,如果 state >= 0
的话,则表示可以获取成功。如果获取成功的话,使用 CAS 操作去修改 state
的值 state=state-1
。如果 state<0
的话,则表示许可证数量不足。此时会创建一个 Node 节点加入阻塞队列,挂起当前线程。
/**\n * 获取1个许可证\n */\npublic void acquire() throws InterruptedException {\n sync.acquireSharedInterruptibly(1);\n}\n/**\n * 共享模式下获取许可证,获取成功则返回,失败则加入阻塞队列,挂起线程\n */\npublic final void acquireSharedInterruptibly(int arg)\n throws InterruptedException {\n if (Thread.interrupted())\n throw new InterruptedException();\n // 尝试获取许可证,arg为获取许可证个数,当可用许可证数减当前获取的许可证数结果小于0,则创建一个节点加入阻塞队列,挂起当前线程。\n if (tryAcquireShared(arg) < 0)\n doAcquireSharedInterruptibly(arg);\n}\n
调用semaphore.release();
,线程尝试释放许可证,并使用 CAS 操作去修改 state
的值 state=state+1
。释放许可证成功之后,同时会唤醒同步队列中的一个线程。被唤醒的线程会重新尝试去修改 state
的值 state=state-1
,如果 state>=0
则获取令牌成功,否则重新进入阻塞队列,挂起线程。
// 释放一个许可证\npublic void release() {\n sync.releaseShared(1);\n}\n\n// 释放共享锁,同时会唤醒同步队列中的一个线程。\npublic final boolean releaseShared(int arg) {\n //释放共享锁\n if (tryReleaseShared(arg)) {\n //唤醒同步队列中的一个线程\n doReleaseShared();\n return true;\n }\n return false;\n}\n
CountDownLatch
允许 count
个线程阻塞在一个地方,直至所有线程的任务都执行完毕。
CountDownLatch
是一次性的,计数器的值只能在构造方法中初始化一次,之后没有任何机制再次对其设置值,当 CountDownLatch
使用完毕后,它不能再次被使用。
CountDownLatch
是共享锁的一种实现,它默认构造 AQS 的 state
值为 count
。当线程使用 countDown()
方法时,其实使用了tryReleaseShared
方法以 CAS 的操作来减少 state
,直至 state
为 0 。当调用 await()
方法的时候,如果 state
不为 0,那就证明任务还没有执行完毕,await()
方法就会一直阻塞,也就是说 await()
方法之后的语句不会被执行。直到count
个线程调用了countDown()
使 state 值被减为 0,或者调用await()
的线程被中断,该线程才会从阻塞中被唤醒,await()
方法之后的语句得到执行。
CountDownLatch
的作用就是 允许 count 个线程阻塞在一个地方,直至所有线程的任务都执行完毕。之前在项目中,有一个使用多线程读取多个文件处理的场景,我用到了 CountDownLatch
。具体场景是下面这样的:
我们要读取处理 6 个文件,这 6 个任务都是没有执行顺序依赖的任务,但是我们需要返回给用户的时候将这几个文件的处理的结果进行统计整理。
\n为此我们定义了一个线程池和 count 为 6 的CountDownLatch
对象 。使用线程池处理读取任务,每一个线程处理完之后就将 count-1,调用CountDownLatch
对象的 await()
方法,直到所有文件读取完之后,才会接着执行后面的逻辑。
伪代码是下面这样的:
\npublic class CountDownLatchExample1 {\n // 处理文件的数量\n private static final int threadCount = 6;\n\n public static void main(String[] args) throws InterruptedException {\n // 创建一个具有固定线程数量的线程池对象(推荐使用构造方法创建)\n ExecutorService threadPool = Executors.newFixedThreadPool(10);\n final CountDownLatch countDownLatch = new CountDownLatch(threadCount);\n for (int i = 0; i < threadCount; i++) {\n final int threadnum = i;\n threadPool.execute(() -> {\n try {\n //处理文件的业务操作\n //......\n } catch (InterruptedException e) {\n e.printStackTrace();\n } finally {\n //表示一个文件已经被完成\n countDownLatch.countDown();\n }\n\n });\n }\n countDownLatch.await();\n threadPool.shutdown();\n System.out.println(\"finish\");\n }\n}\n
有没有可以改进的地方呢?
\n可以使用 CompletableFuture
类来改进!Java8 的 CompletableFuture
提供了很多对多线程友好的方法,使用它可以很方便地为我们编写多线程程序,什么异步、串行、并行或者等待所有线程执行完任务什么的都非常方便。
CompletableFuture<Void> task1 =\n CompletableFuture.supplyAsync(()->{\n //自定义业务操作\n });\n......\nCompletableFuture<Void> task6 =\n CompletableFuture.supplyAsync(()->{\n //自定义业务操作\n });\n......\nCompletableFuture<Void> headerFuture=CompletableFuture.allOf(task1,.....,task6);\n\ntry {\n headerFuture.join();\n} catch (Exception ex) {\n //......\n}\nSystem.out.println(\"all done. \");\n
上面的代码还可以继续优化,当任务过多的时候,把每一个 task 都列出来不太现实,可以考虑通过循环来添加任务。
\n//文件夹位置\nList<String> filePaths = Arrays.asList(...)\n// 异步处理所有文件\nList<CompletableFuture<String>> fileFutures = filePaths.stream()\n .map(filePath -> doSomeThing(filePath))\n .collect(Collectors.toList());\n// 将他们合并起来\nCompletableFuture<Void> allFutures = CompletableFuture.allOf(\n fileFutures.toArray(new CompletableFuture[fileFutures.size()])\n);\n
CyclicBarrier
和 CountDownLatch
非常类似,它也可以实现线程间的技术等待,但是它的功能比 CountDownLatch
更加复杂和强大。主要应用场景和 CountDownLatch
类似。
\n\n\n
CountDownLatch
的实现是基于 AQS 的,而CycliBarrier
是基于ReentrantLock
(ReentrantLock
也属于 AQS 同步器)和Condition
的。
CyclicBarrier
的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是:让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。
CyclicBarrier
内部通过一个 count
变量作为计数器,count
的初始值为 parties
属性的初始化值,每当一个线程到了栅栏这里了,那么就将计数器减 1。如果 count 值为 0 了,表示这是这一代最后一个线程到达栅栏,就尝试执行我们构造方法中输入的任务。
//每次拦截的线程数\nprivate final int parties;\n//计数器\nprivate int count;\n
下面我们结合源码来简单看看。
\n1、CyclicBarrier
默认的构造方法是 CyclicBarrier(int parties)
,其参数表示屏障拦截的线程数量,每个线程调用 await()
方法告诉 CyclicBarrier
我已经到达了屏障,然后当前线程被阻塞。
public CyclicBarrier(int parties) {\n this(parties, null);\n}\n\npublic CyclicBarrier(int parties, Runnable barrierAction) {\n if (parties <= 0) throw new IllegalArgumentException();\n this.parties = parties;\n this.count = parties;\n this.barrierCommand = barrierAction;\n}\n
其中,parties
就代表了有拦截的线程的数量,当拦截的线程数量达到这个值的时候就打开栅栏,让所有线程通过。
2、当调用 CyclicBarrier
对象调用 await()
方法时,实际上调用的是 dowait(false, 0L)
方法。 await()
方法就像树立起一个栅栏的行为一样,将线程挡住了,当拦住的线程数量达到 parties
的值时,栅栏才会打开,线程才得以通过执行。
public int await() throws InterruptedException, BrokenBarrierException {\n try {\n return dowait(false, 0L);\n } catch (TimeoutException toe) {\n throw new Error(toe); // cannot happen\n }\n}\n
dowait(false, 0L)
方法源码分析如下:
// 当线程数量或者请求数量达到 count 时 await 之后的方法才会被执行。上面的示例中 count 的值就为 5。\n private int count;\n /**\n * Main barrier code, covering the various policies.\n */\n private int dowait(boolean timed, long nanos)\n throws InterruptedException, BrokenBarrierException,\n TimeoutException {\n final ReentrantLock lock = this.lock;\n // 锁住\n lock.lock();\n try {\n final Generation g = generation;\n\n if (g.broken)\n throw new BrokenBarrierException();\n\n // 如果线程中断了,抛出异常\n if (Thread.interrupted()) {\n breakBarrier();\n throw new InterruptedException();\n }\n // cout减1\n int index = --count;\n // 当 count 数量减为 0 之后说明最后一个线程已经到达栅栏了,也就是达到了可以执行await 方法之后的条件\n if (index == 0) { // tripped\n boolean ranAction = false;\n try {\n final Runnable command = barrierCommand;\n if (command != null)\n command.run();\n ranAction = true;\n // 将 count 重置为 parties 属性的初始化值\n // 唤醒之前等待的线程\n // 下一波执行开始\n nextGeneration();\n return 0;\n } finally {\n if (!ranAction)\n breakBarrier();\n }\n }\n\n // loop until tripped, broken, interrupted, or timed out\n for (;;) {\n try {\n if (!timed)\n trip.await();\n else if (nanos > 0L)\n nanos = trip.awaitNanos(nanos);\n } catch (InterruptedException ie) {\n if (g == generation && ! g.broken) {\n breakBarrier();\n throw ie;\n } else {\n // We're about to finish waiting even if we had not\n // been interrupted, so this interrupt is deemed to\n // \"belong\" to subsequent execution.\n Thread.currentThread().interrupt();\n }\n }\n\n if (g.broken)\n throw new BrokenBarrierException();\n\n if (g != generation)\n return index;\n\n if (timed && nanos <= 0L) {\n breakBarrier();\n throw new TimeoutException();\n }\n }\n } finally {\n lock.unlock();\n }\n }\n
虚拟线程在 Java 21 正式发布,这是一项重量级的更新。
\n虽然目前面试中问的不多,但还是建议大家去简单了解一下,具体可以阅读这篇文章:虚拟线程极简入门 。重点搞清楚虚拟线程和平台线程的关系以及虚拟线程的优势即可。
\n\n\n本文来自 Kingshion 投稿。欢迎更多朋友参与到 JavaGuide 的维护工作,这是一件非常有意义的事情。详细信息请看:JavaGuide 贡献指南 。
\n
在面向对象的设计原则中,一般推荐模块之间基于接口编程,通常情况下调用方模块是不会感知到被调用方模块的内部具体实现。一旦代码里面涉及具体实现类,就违反了开闭原则。如果需要替换一种实现,就需要修改代码。
\n为了实现在模块装配的时候不用在程序里面动态指明,这就需要一种服务发现机制。Java SPI 就是提供了这样一个机制:为某个接口寻找服务实现的机制。这有点类似 IoC 的思想,将装配的控制权移交到了程序之外。
\nSPI 即 Service Provider Interface ,字面意思就是:“服务提供者的接口”,我的理解是:专门提供给服务提供者或者扩展框架功能的开发者去使用的一个接口。
\nSPI 将服务接口和具体的服务实现分离开来,将服务调用方和服务实现者解耦,能够提升程序的扩展性、可维护性。修改或者替换服务实现并不需要修改调用方。
\n很多框架都使用了 Java 的 SPI 机制,比如:Spring 框架、数据库加载驱动、日志接口、以及 Dubbo 的扩展实现等等。
\n\n那 SPI 和 API 有啥区别?
\n说到 SPI 就不得不说一下 API 了,从广义上来说它们都属于接口,而且很容易混淆。下面先用一张图说明一下:
\n\n一般模块之间都是通过通过接口进行通讯,那我们在服务调用方和服务实现方(也称服务提供者)之间引入一个“接口”。
\n当实现方提供了接口和实现,我们可以通过调用实现方的接口从而拥有实现方给我们提供的能力,这就是 API ,这种接口和实现都是放在实现方的。
\n当接口存在于调用方这边时,就是 SPI ,由接口调用方确定接口规则,然后由不同的厂商去根据这个规则对这个接口进行实现,从而提供服务。
\n举个通俗易懂的例子:公司 H 是一家科技公司,新设计了一款芯片,然后现在需要量产了,而市面上有好几家芯片制造业公司,这个时候,只要 H 公司指定好了这芯片生产的标准(定义好了接口标准),那么这些合作的芯片公司(服务提供者)就按照标准交付自家特色的芯片(提供不同方案的实现,但是给出来的结果是一样的)。
\nSLF4J (Simple Logging Facade for Java)是 Java 的一个日志门面(接口),其具体实现有几种,比如:Logback、Log4j、Log4j2 等等,而且还可以切换,在切换日志具体实现的时候我们是不需要更改项目代码的,只需要在 Maven 依赖里面修改一些 pom 依赖就好了。
\n\n这就是依赖 SPI 机制实现的,那我们接下来就实现一个简易版本的日志框架。
\n新建一个 Java 项目 service-provider-interface
目录结构如下:(注意直接新建 Java 项目就好了,不用新建 Maven 项目,Maven 项目会涉及到一些编译配置,如果有私服的话,直接 deploy 会比较方便,但是没有的话,在过程中可能会遇到一些奇怪的问题。)
│ service-provider-interface.iml\n│\n├─.idea\n│ │ .gitignore\n│ │ misc.xml\n│ │ modules.xml\n│ └─ workspace.xml\n│\n└─src\n └─edu\n └─jiangxuan\n └─up\n └─spi\n Logger.java\n LoggerService.java\n Main.class\n
新建 Logger
接口,这个就是 SPI , 服务提供者接口,后面的服务提供者就要针对这个接口进行实现。
package edu.jiangxuan.up.spi;\n\npublic interface Logger {\n void info(String msg);\n void debug(String msg);\n}\n
接下来就是 LoggerService
类,这个主要是为服务使用者(调用方)提供特定功能的。这个类也是实现 Java SPI 机制的关键所在,如果存在疑惑的话可以先往后面继续看。
package edu.jiangxuan.up.spi;\n\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.ServiceLoader;\n\npublic class LoggerService {\n private static final LoggerService SERVICE = new LoggerService();\n\n private final Logger logger;\n\n private final List<Logger> loggerList;\n\n private LoggerService() {\n ServiceLoader<Logger> loader = ServiceLoader.load(Logger.class);\n List<Logger> list = new ArrayList<>();\n for (Logger log : loader) {\n list.add(log);\n }\n // LoggerList 是所有 ServiceProvider\n loggerList = list;\n if (!list.isEmpty()) {\n // Logger 只取一个\n logger = list.get(0);\n } else {\n logger = null;\n }\n }\n\n public static LoggerService getService() {\n return SERVICE;\n }\n\n public void info(String msg) {\n if (logger == null) {\n System.out.println(\"info 中没有发现 Logger 服务提供者\");\n } else {\n logger.info(msg);\n }\n }\n\n public void debug(String msg) {\n if (loggerList.isEmpty()) {\n System.out.println(\"debug 中没有发现 Logger 服务提供者\");\n }\n loggerList.forEach(log -> log.debug(msg));\n }\n}\n
新建 Main
类(服务使用者,调用方),启动程序查看结果。
package org.spi.service;\n\npublic class Main {\n public static void main(String[] args) {\n LoggerService service = LoggerService.getService();\n\n service.info(\"Hello SPI\");\n service.debug(\"Hello SPI\");\n }\n}\n
程序结果:
\n\n\ninfo 中没有发现 Logger 服务提供者
\n
\ndebug 中没有发现 Logger 服务提供者
此时我们只是空有接口,并没有为 Logger
接口提供任何的实现,所以输出结果中没有按照预期打印相应的结果。
你可以使用命令或者直接使用 IDEA 将整个程序直接打包成 jar 包。
\n接下来新建一个项目用来实现 Logger
接口
新建项目 service-provider
目录结构如下:
│ service-provider.iml\n│\n├─.idea\n│ │ .gitignore\n│ │ misc.xml\n│ │ modules.xml\n│ └─ workspace.xml\n│\n├─lib\n│ service-provider-interface.jar\n|\n└─src\n ├─edu\n │ └─jiangxuan\n │ └─up\n │ └─spi\n │ └─service\n │ Logback.java\n │\n └─META-INF\n └─services\n edu.jiangxuan.up.spi.Logger\n\n
新建 Logback
类
package edu.jiangxuan.up.spi.service;\n\nimport edu.jiangxuan.up.spi.Logger;\n\npublic class Logback implements Logger {\n @Override\n public void info(String s) {\n System.out.println(\"Logback info 打印日志:\" + s);\n }\n\n @Override\n public void debug(String s) {\n System.out.println(\"Logback debug 打印日志:\" + s);\n }\n}\n\n
将 service-provider-interface
的 jar 导入项目中。
新建 lib 目录,然后将 jar 包拷贝过来,再添加到项目中。
\n\n再点击 OK 。
\n\n接下来就可以在项目中导入 jar 包里面的一些类和方法了,就像 JDK 工具类导包一样的。
\n实现 Logger
接口,在 src
目录下新建 META-INF/services
文件夹,然后新建文件 edu.jiangxuan.up.spi.Logger
(SPI 的全类名),文件里面的内容是:edu.jiangxuan.up.spi.service.Logback
(Logback 的全类名,即 SPI 的实现类的包名 + 类名)。
这是 JDK SPI 机制 ServiceLoader 约定好的标准。
\n这里先大概解释一下:Java 中的 SPI 机制就是在每次类加载的时候会先去找到 class 相对目录下的 META-INF
文件夹下的 services 文件夹下的文件,将这个文件夹下面的所有文件先加载到内存中,然后根据这些文件的文件名和里面的文件内容找到相应接口的具体实现类,找到实现类后就可以通过反射去生成对应的对象,保存在一个 list 列表里面,所以可以通过迭代或者遍历的方式拿到对应的实例对象,生成不同的实现。
所以会提出一些规范要求:文件名一定要是接口的全类名,然后里面的内容一定要是实现类的全类名,实现类可以有多个,直接换行就好了,多个实现类的时候,会一个一个的迭代加载。
\n接下来同样将 service-provider
项目打包成 jar 包,这个 jar 包就是服务提供方的实现。通常我们导入 maven 的 pom 依赖就有点类似这种,只不过我们现在没有将这个 jar 包发布到 maven 公共仓库中,所以在需要使用的地方只能手动的添加到项目中。
为了更直观的展示效果,我这里再新建一个专门用来测试的工程项目:java-spi-test
然后先导入 Logger
的接口 jar 包,再导入具体的实现类的 jar 包。
新建 Main 方法测试:
\npackage edu.jiangxuan.up.service;\n\nimport edu.jiangxuan.up.spi.LoggerService;\n\npublic class TestJavaSPI {\n public static void main(String[] args) {\n LoggerService loggerService = LoggerService.getService();\n loggerService.info(\"你好\");\n loggerService.debug(\"测试Java SPI 机制\");\n }\n}\n
运行结果如下:
\n\n\nLogback info 打印日志:你好
\n
\nLogback debug 打印日志:测试 Java SPI 机制
说明导入 jar 包中的实现类生效了。
\n如果我们不导入具体的实现类的 jar 包,那么此时程序运行的结果就会是:
\n\n\ninfo 中没有发现 Logger 服务提供者
\n
\ndebug 中没有发现 Logger 服务提供者
通过使用 SPI 机制,可以看出服务(LoggerService
)和 服务提供者两者之间的耦合度非常低,如果说我们想要换一种实现,那么其实只需要修改 service-provider
项目中针对 Logger
接口的具体实现就可以了,只需要换一个 jar 包即可,也可以有在一个项目里面有多个实现,这不就是 SLF4J 原理吗?
如果某一天需求变更了,此时需要将日志输出到消息队列,或者做一些别的操作,这个时候完全不需要更改 Logback 的实现,只需要新增一个服务实现(service-provider)可以通过在本项目里面新增实现也可以从外部引入新的服务实现 jar 包。我们可以在服务(LoggerService)中选择一个具体的 服务实现(service-provider) 来完成我们需要的操作。
\n那么接下来我们具体来说说 Java SPI 工作的重点原理—— ServiceLoader 。
\n想要使用 Java 的 SPI 机制是需要依赖 ServiceLoader
来实现的,那么我们接下来看看 ServiceLoader
具体是怎么做的:
ServiceLoader
是 JDK 提供的一个工具类, 位于package java.util;
包下。
A facility to load implementations of a service.\n
这是 JDK 官方给的注释:一种加载服务实现的工具。
\n再往下看,我们发现这个类是一个 final
类型的,所以是不可被继承修改,同时它实现了 Iterable
接口。之所以实现了迭代器,是为了方便后续我们能够通过迭代的方式得到对应的服务实现。
public final class ServiceLoader<S> implements Iterable<S>{ xxx...}\n
可以看到一个熟悉的常量定义:
\nprivate static final String PREFIX = \"META-INF/services/\";
下面是 load
方法:可以发现 load
方法支持两种重载后的入参;
public static <S> ServiceLoader<S> load(Class<S> service) {\n ClassLoader cl = Thread.currentThread().getContextClassLoader();\n return ServiceLoader.load(service, cl);\n}\n\npublic static <S> ServiceLoader<S> load(Class<S> service,\n ClassLoader loader) {\n return new ServiceLoader<>(service, loader);\n}\n\nprivate ServiceLoader(Class<S> svc, ClassLoader cl) {\n service = Objects.requireNonNull(svc, \"Service interface cannot be null\");\n loader = (cl == null) ? ClassLoader.getSystemClassLoader() : cl;\n acc = (System.getSecurityManager() != null) ? AccessController.getContext() : null;\n reload();\n}\n\npublic void reload() {\n providers.clear();\n lookupIterator = new LazyIterator(service, loader);\n}\n
根据代码的调用顺序,在 reload()
方法中是通过一个内部类 LazyIterator
实现的。先继续往下面看。
ServiceLoader
实现了 Iterable
接口的方法后,具有了迭代的能力,在这个 iterator
方法被调用时,首先会在 ServiceLoader
的 Provider
缓存中进行查找,如果缓存中没有命中那么则在 LazyIterator
中进行查找。
\npublic Iterator<S> iterator() {\n return new Iterator<S>() {\n\n Iterator<Map.Entry<String, S>> knownProviders\n = providers.entrySet().iterator();\n\n public boolean hasNext() {\n if (knownProviders.hasNext())\n return true;\n return lookupIterator.hasNext(); // 调用 LazyIterator\n }\n\n public S next() {\n if (knownProviders.hasNext())\n return knownProviders.next().getValue();\n return lookupIterator.next(); // 调用 LazyIterator\n }\n\n public void remove() {\n throw new UnsupportedOperationException();\n }\n\n };\n}\n
在调用 LazyIterator
时,具体实现如下:
\npublic boolean hasNext() {\n if (acc == null) {\n return hasNextService();\n } else {\n PrivilegedAction<Boolean> action = new PrivilegedAction<Boolean>() {\n public Boolean run() {\n return hasNextService();\n }\n };\n return AccessController.doPrivileged(action, acc);\n }\n}\n\nprivate boolean hasNextService() {\n if (nextName != null) {\n return true;\n }\n if (configs == null) {\n try {\n //通过PREFIX(META-INF/services/)和类名 获取对应的配置文件,得到具体的实现类\n String fullName = PREFIX + service.getName();\n if (loader == null)\n configs = ClassLoader.getSystemResources(fullName);\n else\n configs = loader.getResources(fullName);\n } catch (IOException x) {\n fail(service, \"Error locating configuration files\", x);\n }\n }\n while ((pending == null) || !pending.hasNext()) {\n if (!configs.hasMoreElements()) {\n return false;\n }\n pending = parse(service, configs.nextElement());\n }\n nextName = pending.next();\n return true;\n}\n\n\npublic S next() {\n if (acc == null) {\n return nextService();\n } else {\n PrivilegedAction<S> action = new PrivilegedAction<S>() {\n public S run() {\n return nextService();\n }\n };\n return AccessController.doPrivileged(action, acc);\n }\n}\n\nprivate S nextService() {\n if (!hasNextService())\n throw new NoSuchElementException();\n String cn = nextName;\n nextName = null;\n Class<?> c = null;\n try {\n c = Class.forName(cn, false, loader);\n } catch (ClassNotFoundException x) {\n fail(service,\n \"Provider \" + cn + \" not found\");\n }\n if (!service.isAssignableFrom(c)) {\n fail(service,\n \"Provider \" + cn + \" not a subtype\");\n }\n try {\n S p = service.cast(c.newInstance());\n providers.put(cn, p);\n return p;\n } catch (Throwable x) {\n fail(service,\n \"Provider \" + cn + \" could not be instantiated\",\n x);\n }\n throw new Error(); // This cannot happen\n}\n
可能很多人看这个会觉得有点复杂,没关系,我这边实现了一个简单的 ServiceLoader
的小模型,流程和原理都是保持一致的,可以先从自己实现一个简易版本的开始学:
我先把代码贴出来:
\npackage edu.jiangxuan.up.service;\n\nimport java.io.BufferedReader;\nimport java.io.InputStream;\nimport java.io.InputStreamReader;\nimport java.lang.reflect.Constructor;\nimport java.net.URL;\nimport java.net.URLConnection;\nimport java.util.ArrayList;\nimport java.util.Enumeration;\nimport java.util.List;\n\npublic class MyServiceLoader<S> {\n\n // 对应的接口 Class 模板\n private final Class<S> service;\n\n // 对应实现类的 可以有多个,用 List 进行封装\n private final List<S> providers = new ArrayList<>();\n\n // 类加载器\n private final ClassLoader classLoader;\n\n // 暴露给外部使用的方法,通过调用这个方法可以开始加载自己定制的实现流程。\n public static <S> MyServiceLoader<S> load(Class<S> service) {\n return new MyServiceLoader<>(service);\n }\n\n // 构造方法私有化\n private MyServiceLoader(Class<S> service) {\n this.service = service;\n this.classLoader = Thread.currentThread().getContextClassLoader();\n doLoad();\n }\n\n // 关键方法,加载具体实现类的逻辑\n private void doLoad() {\n try {\n // 读取所有 jar 包里面 META-INF/services 包下面的文件,这个文件名就是接口名,然后文件里面的内容就是具体的实现类的路径加全类名\n Enumeration<URL> urls = classLoader.getResources(\"META-INF/services/\" + service.getName());\n // 挨个遍历取到的文件\n while (urls.hasMoreElements()) {\n // 取出当前的文件\n URL url = urls.nextElement();\n System.out.println(\"File = \" + url.getPath());\n // 建立链接\n URLConnection urlConnection = url.openConnection();\n urlConnection.setUseCaches(false);\n // 获取文件输入流\n InputStream inputStream = urlConnection.getInputStream();\n // 从文件输入流获取缓存\n BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream));\n // 从文件内容里面得到实现类的全类名\n String className = bufferedReader.readLine();\n\n while (className != null) {\n // 通过反射拿到实现类的实例\n Class<?> clazz = Class.forName(className, false, classLoader);\n // 如果声明的接口跟这个具体的实现类是属于同一类型,(可以理解为Java的一种多态,接口跟实现类、父类和子类等等这种关系。)则构造实例\n if (service.isAssignableFrom(clazz)) {\n Constructor<? extends S> constructor = (Constructor<? extends S>) clazz.getConstructor();\n S instance = constructor.newInstance();\n // 把当前构造的实例对象添加到 Provider的列表里面\n providers.add(instance);\n }\n // 继续读取下一行的实现类,可以有多个实现类,只需要换行就可以了。\n className = bufferedReader.readLine();\n }\n }\n } catch (Exception e) {\n System.out.println(\"读取文件异常。。。\");\n }\n }\n\n // 返回spi接口对应的具体实现类列表\n public List<S> getProviders() {\n return providers;\n }\n}\n
关键信息基本已经通过代码注释描述出来了,
\n主要的流程就是:
\n/META-INF/services
目录下面找到对应的文件,InputStream
流将文件里面的具体实现类的全类名读取出来,Providers
的列表中。其实不难发现,SPI 机制的具体实现本质上还是通过反射完成的。即:我们按照规定将要暴露对外使用的具体实现类在 META-INF/services/
文件下声明。
另外,SPI 机制在很多框架中都有应用:Spring 框架的基本原理也是类似的方式。还有 Dubbo 框架提供同样的 SPI 扩展机制,只不过 Dubbo 和 spring 框架中的 SPI 机制具体实现方式跟咱们今天学得这个有些细微的区别,不过整体的原理都是一致的,相信大家通过对 JDK 中 SPI 机制的学习,能够一通百通,加深对其他高深框的理解。
\n通过 SPI 机制能够大大地提高接口设计的灵活性,但是 SPI 机制也存在一些缺点,比如:
\nServiceLoader
同时 load
时,会有并发问题。你可以将 Redis 中的事务理解为:Redis 事务提供了一种将多个命令请求打包的功能。然后,再按顺序执行打包的所有命令,并且不会被中途打断。
\nRedis 事务实际开发中使用的非常少,功能比较鸡肋,不要将其和我们平时理解的关系型数据库的事务混淆了。
\n除了不满足原子性和持久性之外,事务中的每条命令都会与 Redis 服务器进行网络交互,这是比较浪费资源的行为。明明一次批量执行多个命令就可以了,这种操作实在是看不懂。
\n因此,Redis 事务是不建议在日常开发中使用的。
\nRedis 可以通过 MULTI
,EXEC
,DISCARD
和 WATCH
等命令来实现事务(Transaction)功能。
> MULTI\nOK\n> SET PROJECT \"JavaGuide\"\nQUEUED\n> GET PROJECT\nQUEUED\n> EXEC\n1) OK\n2) \"JavaGuide\"\n
MULTI
命令后可以输入多个命令,Redis 不会立即执行这些命令,而是将它们放到队列,当调用了 EXEC
命令后,再执行所有的命令。
这个过程是这样的:
\nMULTI
);EXEC
)。你也可以通过 DISCARD
命令取消一个事务,它会清空事务队列中保存的所有命令。
> MULTI\nOK\n> SET PROJECT \"JavaGuide\"\nQUEUED\n> GET PROJECT\nQUEUED\n> DISCARD\nOK\n
你可以通过WATCH
命令监听指定的 Key,当调用 EXEC
命令执行事务时,如果一个被 WATCH
命令监视的 Key 被 其他客户端/Session 修改的话,整个事务都不会被执行。
# 客户端 1\n> SET PROJECT \"RustGuide\"\nOK\n> WATCH PROJECT\nOK\n> MULTI\nOK\n> SET PROJECT \"JavaGuide\"\nQUEUED\n\n# 客户端 2\n# 在客户端 1 执行 EXEC 命令提交事务之前修改 PROJECT 的值\n> SET PROJECT \"GoGuide\"\n\n# 客户端 1\n# 修改失败,因为 PROJECT 的值被客户端2修改了\n> EXEC\n(nil)\n> GET PROJECT\n\"GoGuide\"\n
不过,如果 WATCH 与 事务 在同一个 Session 里,并且被 WATCH 监视的 Key 被修改的操作发生在事务内部,这个事务是可以被执行成功的(相关 issue:WATCH 命令碰到 MULTI 命令时的不同效果)。
\n事务内部修改 WATCH 监视的 Key:
\n> SET PROJECT \"JavaGuide\"\nOK\n> WATCH PROJECT\nOK\n> MULTI\nOK\n> SET PROJECT \"JavaGuide1\"\nQUEUED\n> SET PROJECT \"JavaGuide2\"\nQUEUED\n> SET PROJECT \"JavaGuide3\"\nQUEUED\n> EXEC\n1) OK\n2) OK\n3) OK\n127.0.0.1:6379> GET PROJECT\n\"JavaGuide3\"\n
事务外部修改 WATCH 监视的 Key:
\n> SET PROJECT \"JavaGuide\"\nOK\n> WATCH PROJECT\nOK\n> SET PROJECT \"JavaGuide2\"\nOK\n> MULTI\nOK\n> GET USER\nQUEUED\n> EXEC\n(nil)\n
Redis 官网相关介绍 https://redis.io/topics/transactions 如下:
\n\nRedis 的事务和我们平时理解的关系型数据库的事务不同。我们知道事务具有四大特性:1. 原子性,2. 隔离性,3. 持久性,4. 一致性。
\nRedis 事务在运行错误的情况下,除了执行过程中出现错误的命令外,其他命令都能正常执行。并且,Redis 事务是不支持回滚(roll back)操作的。因此,Redis 事务其实是不满足原子性的。
\nRedis 官网也解释了自己为啥不支持回滚。简单来说就是 Redis 开发者们觉得没必要支持回滚,这样更简单便捷并且性能更好。Redis 开发者觉得即使命令执行错误也应该在开发过程中就被发现而不是生产过程中。
\n\n相关 issue :
\n\nRedis 不同于 Memcached 的很重要一点就是,Redis 支持持久化,而且支持 3 种持久化方式:
\n与 RDB 持久化相比,AOF 持久化的实时性更好。在 Redis 的配置文件中存在三种不同的 AOF 持久化方式( fsync
策略),它们分别是:
appendfsync always #每次有数据修改发生时都会调用fsync函数同步AOF文件,fsync完成后线程返回,这样会严重降低Redis的速度\nappendfsync everysec #每秒钟调用fsync函数同步一次AOF文件\nappendfsync no #让操作系统决定何时进行同步,一般为30秒一次\n
AOF 持久化的fsync
策略为 no、everysec 时都会存在数据丢失的情况 。always 下可以基本是可以满足持久性要求的,但性能太差,实际开发过程中不会使用。
因此,Redis 事务的持久性也是没办法保证的。
\nRedis 从 2.6 版本开始支持执行 Lua 脚本,它的功能和事务非常类似。我们可以利用 Lua 脚本来批量执行多条 Redis 命令,这些 Redis 命令会被提交到 Redis 服务器一次性执行完成,大幅减小了网络开销。
\n一段 Lua 脚本可以视作一条命令执行,一段 Lua 脚本执行过程中不会有其他脚本或 Redis 命令同时执行,保证了操作不会被其他指令插入或打扰。
\n不过,如果 Lua 脚本运行时出错并中途结束,出错之后的命令是不会被执行的。并且,出错之前执行的命令是无法被撤销的,无法实现类似关系型数据库执行失败可以回滚的那种原子性效果。因此, 严格来说的话,通过 Lua 脚本来批量执行 Redis 命令实际也是不完全满足原子性的。
\n如果想要让 Lua 脚本中的命令全部执行,必须保证语句语法和命令都是对的。
\n另外,Redis 7.0 新增了 Redis functions 特性,你可以将 Redis functions 看作是比 Lua 更强大的脚本。
\n除了下面介绍的内容之外,再推荐两篇不错的文章:
\n\n一个 Redis 命令的执行可以简化为以下 4 步:
\n其中,第 1 步和第 4 步耗费时间之和称为 Round Trip Time (RTT,往返时间) ,也就是数据在网络上传输的时间。
\n使用批量操作可以减少网络传输次数,进而有效减小网络开销,大幅减少 RTT。
\n另外,除了能减少 RTT 之外,发送一次命令的 socket I/O 成本也比较高(涉及上下文切换,存在read()
和write()
系统调用),批量操作还可以减少 socket I/O 成本。这个在官方对 pipeline 的介绍中有提到:https://redis.io/docs/manual/pipelining/ 。
Redis 中有一些原生支持批量操作的命令,比如:
\nMGET
(获取一个或多个指定 key 的值)、MSET
(设置一个或多个指定 key 的值)、HMGET
(获取指定哈希表中一个或者多个指定字段的值)、HMSET
(同时将一个或多个 field-value 对设置到指定哈希表中)、SADD
(向指定集合添加一个或多个元素)不过,在 Redis 官方提供的分片集群解决方案 Redis Cluster 下,使用这些原生批量操作命令可能会存在一些小问题需要解决。就比如说 MGET
无法保证所有的 key 都在同一个 hash slot(哈希槽)上,MGET
可能还是需要多次网络传输,原子操作也无法保证了。不过,相较于非批量操作,还是可以节省不少网络传输次数。
整个步骤的简化版如下(通常由 Redis 客户端实现,无需我们自己再手动实现):
\nMGET
请求获取数据;如果想要解决这个多次网络传输的问题,比较常用的办法是自己维护 key 与 slot 的关系。不过这样不太灵活,虽然带来了性能提升,但同样让系统复杂性提升。
\n\n\nRedis Cluster 并没有使用一致性哈希,采用的是 哈希槽分区 ,每一个键值对都属于一个 hash slot(哈希槽) 。当客户端发送命令请求的时候,需要先根据 key 通过上面的计算公示找到的对应的哈希槽,然后再查询哈希槽和节点的映射关系,即可找到目标 Redis 节点。
\n我在 Redis 集群详解(付费) 这篇文章中详细介绍了 Redis Cluster 这部分的内容,感兴趣地可以看看。
\n
对于不支持批量操作的命令,我们可以利用 pipeline(流水线) 将一批 Redis 命令封装成一组,这些 Redis 命令会被一次性提交到 Redis 服务器,只需要一次网络传输。不过,需要注意控制一次批量操作的 元素个数(例如 500 以内,实际也和元素字节数有关),避免网络传输的数据量过大。
\n与MGET
、MSET
等原生批量操作命令一样,pipeline 同样在 Redis Cluster 上使用会存在一些小问题。原因类似,无法保证所有的 key 都在同一个 hash slot(哈希槽)上。如果想要使用的话,客户端需要自己维护 key 与 slot 的关系。
原生批量操作命令和 pipeline 的是有区别的,使用的时候需要注意:
\n顺带补充一下 pipeline 和 Redis 事务的对比:
\n\n\n\n事务可以看作是一个原子操作,但其实并不满足原子性。当我们提到 Redis 中的原子操作时,主要指的是这个操作(比如事务、Lua 脚本)不会被其他操作(比如其他事务、Lua 脚本)打扰,并不能完全保证这个操作中的所有写命令要么都执行要么都不执行。这主要也是因为 Redis 是不支持回滚操作。
\n
另外,pipeline 不适用于执行顺序有依赖关系的一批命令。就比如说,你需要将前一个命令的结果给后续的命令使用,pipeline 就没办法满足你的需求了。对于这种需求,我们可以使用 Lua 脚本 。
\nLua 脚本同样支持批量操作多条命令。一段 Lua 脚本可以视作一条命令执行,可以看作是 原子操作 。也就是说,一段 Lua 脚本执行过程中不会有其他脚本或 Redis 命令同时执行,保证了操作不会被其他指令插入或打扰,这是 pipeline 所不具备的。
\n并且,Lua 脚本中支持一些简单的逻辑处理比如使用命令读取值并在 Lua 脚本中进行处理,这同样是 pipeline 所不具备的。
\n不过, Lua 脚本依然存在下面这些缺陷:
\n我在前面提到过:对于过期 key,Redis 采用的是 定期删除+惰性/懒汉式删除 策略。
\n定期删除执行过程中,如果突然遇到大量过期 key 的话,客户端请求必须等待定期清理过期 key 任务线程执行完成,因为这个这个定期任务线程是在 Redis 主线程中执行的。这就导致客户端请求没办法被及时处理,响应速度会比较慢。
\n如何解决呢? 下面是两种常见的方法:
\n个人建议不管是否开启 lazy-free,我们都尽量给 key 设置随机过期时间。
\n简单来说,如果一个 key 对应的 value 所占用的内存比较大,那这个 key 就可以看作是 bigkey。具体多大才算大呢?有一个不是特别精确的参考标准:
\nbigkey 通常是由于下面这些原因产生的:
\nbigkey 除了会消耗更多的内存空间和带宽,还会对性能造成比较大的影响。
\n在 Redis 常见阻塞原因总结这篇文章中我们提到:大 key 还会造成阻塞问题。具体来说,主要体现在下面三个方面:
\n大 key 造成的阻塞问题还会进一步影响到主从同步和集群扩容。
\n综上,大 key 带来的潜在问题是非常多的,我们应该尽量避免 Redis 中存在 bigkey。
\n1、使用 Redis 自带的 --bigkeys
参数来查找。
# redis-cli -p 6379 --bigkeys\n\n# Scanning the entire keyspace to find biggest keys as well as\n# average sizes per key type. You can use -i 0.1 to sleep 0.1 sec\n# per 100 SCAN commands (not usually needed).\n\n[00.00%] Biggest string found so far '\"ballcat:oauth:refresh_auth:f6cdb384-9a9d-4f2f-af01-dc3f28057c20\"' with 4437 bytes\n[00.00%] Biggest list found so far '\"my-list\"' with 17 items\n\n
Redis 共有 5 种基本数据类型:String(字符串)、List(列表)、Set(集合)、Hash(散列)、Zset(有序集合)。
\n这 5 种数据类型是直接提供给用户使用的,是数据的保存形式,其底层实现主要依赖这 8 种数据结构:简单动态字符串(SDS)、LinkedList(双向链表)、Dict(哈希表/字典)、SkipList(跳跃表)、Intset(整数集合)、ZipList(压缩列表)、QuickList(快速列表)。
\nRedis 5 种基本数据类型对应的底层数据结构实现如下表所示:
\n| String | List | Hash | Set | Zset |
\n| :
除了 5 种基本的数据类型之外,Redis 还支持 3 种特殊的数据类型:Bitmap、HyperLogLog、GEO。
\n根据官网介绍:
\n\n\nBitmaps are not an actual data type, but a set of bit-oriented operations defined on the String type which is treated like a bit vector. Since strings are binary safe blobs and their maximum length is 512 MB, they are suitable to set up to 2^32 different bits.
\nBitmap 不是 Redis 中的实际数据类型,而是在 String 类型上定义的一组面向位的操作,将其视为位向量。由于字符串是二进制安全的块,且最大长度为 512 MB,它们适合用于设置最多 2^32 个不同的位。
\n
Bitmap 存储的是连续的二进制数字(0 和 1),通过 Bitmap, 只需要一个 bit 位来表示某个元素对应的值或者状态,key 就是对应元素本身 。我们知道 8 个 bit 可以组成一个 byte,所以 Bitmap 本身会极大的节省储存空间。
\n你可以将 Bitmap 看作是一个存储二进制数字(0 和 1)的数组,数组中每个元素的下标叫做 offset(偏移量)。
\n\n| 命令 | 介绍 |
\n|
\n\n本文整理完善自下面这两篇优秀的文章:
\n\n
阅读过 JUC 源码的同学,一定会发现很多并发工具类都调用了一个叫做 Unsafe
的类。
那这个类主要是用来干什么的呢?有什么使用场景呢?这篇文章就带你搞清楚!
\nUnsafe
是位于 sun.misc
包下的一个类,主要提供一些用于执行低级别、不安全操作的方法,如直接访问系统内存资源、自主管理内存资源等,这些方法在提升 Java 运行效率、增强 Java 语言底层资源操作能力方面起到了很大的作用。但由于 Unsafe
类使 Java 语言拥有了类似 C 语言指针一样操作内存空间的能力,这无疑也增加了程序发生相关指针问题的风险。在程序中过度、不正确使用 Unsafe
类会使得程序出错的概率变大,使得 Java 这种安全的语言变得不再“安全”,因此对 Unsafe
的使用一定要慎重。
另外,Unsafe
提供的这些功能的实现需要依赖本地方法(Native Method)。你可以将本地方法看作是 Java 中使用其他编程语言编写的方法。本地方法使用 native
关键字修饰,Java 代码中只是声明方法头,具体的实现则交给 本地代码。
为什么要使用本地方法呢?
\n在 JUC 包的很多并发工具类在实现并发机制时,都调用了本地方法,通过它们打破了 Java 运行时的界限,能够接触到操作系统底层的某些功能。对于同一本地方法,不同的操作系统可能会通过不同的方式来实现,但是对于使用者来说是透明的,最终都会得到相同的结果。
\nsun.misc.Unsafe
部分源码如下:
public final class Unsafe {\n // 单例对象\n private static final Unsafe theUnsafe;\n ......\n private Unsafe() {\n }\n @CallerSensitive\n public static Unsafe getUnsafe() {\n Class var0 = Reflection.getCallerClass();\n // 仅在引导类加载器`BootstrapClassLoader`加载时才合法\n if(!VM.isSystemDomainLoader(var0.getClassLoader())) {\n throw new SecurityException(\"Unsafe\");\n } else {\n return theUnsafe;\n }\n }\n}\n
Unsafe
类为一单例实现,提供静态方法 getUnsafe
获取 Unsafe
实例。这个看上去貌似可以用来获取 Unsafe
实例。但是,当我们直接调用这个静态方法的时候,会抛出 SecurityException
异常:
Exception in thread \"main\" java.lang.SecurityException: Unsafe\n at sun.misc.Unsafe.getUnsafe(Unsafe.java:90)\n at com.cn.test.GetUnsafeTest.main(GetUnsafeTest.java:12)\n
为什么 public static
方法无法被直接调用呢?
这是因为在getUnsafe
方法中,会对调用者的classLoader
进行检查,判断当前类是否由Bootstrap classLoader
加载,如果不是的话那么就会抛出一个SecurityException
异常。也就是说,只有启动类加载器加载的类才能够调用 Unsafe 类中的方法,来防止这些方法在不可信的代码中被调用。
为什么要对 Unsafe 类进行这么谨慎的使用限制呢?
\nUnsafe
提供的功能过于底层(如直接访问系统内存资源、自主管理内存资源等),安全隐患也比较大,使用不当的话,很容易出现很严重的问题。
如若想使用 Unsafe
这个类的话,应该如何获取其实例呢?
这里介绍两个可行的方案。
\n1、利用反射获得 Unsafe 类中已经实例化完成的单例对象 theUnsafe
。
private static Unsafe reflectGetUnsafe() {\n try {\n Field field = Unsafe.class.getDeclaredField(\"theUnsafe\");\n field.setAccessible(true);\n return (Unsafe) field.get(null);\n } catch (Exception e) {\n log.error(e.getMessage(), e);\n return null;\n }\n}\n
2、从getUnsafe
方法的使用限制条件出发,通过 Java 命令行命令-Xbootclasspath/a
把调用 Unsafe 相关方法的类 A 所在 jar 包路径追加到默认的 bootstrap 路径中,使得 A 被引导类加载器加载,从而通过Unsafe.getUnsafe
方法安全的获取 Unsafe 实例。
java -Xbootclasspath/a: ${path} // 其中path为调用Unsafe相关方法的类所在jar包路径\n
概括的来说,Unsafe
类实现功能可以被分为下面 8 类:
如果你是一个写过 C 或者 C++ 的程序员,一定对内存操作不会陌生,而在 Java 中是不允许直接对内存进行操作的,对象内存的分配和回收都是由 JVM 自己实现的。但是在 Unsafe
中,提供的下列接口可以直接进行内存操作:
//分配新的本地空间\npublic native long allocateMemory(long bytes);\n//重新调整内存空间的大小\npublic native long reallocateMemory(long address, long bytes);\n//将内存设置为指定值\npublic native void setMemory(Object o, long offset, long bytes, byte value);\n//内存拷贝\npublic native void copyMemory(Object srcBase, long srcOffset,Object destBase, long destOffset,long bytes);\n//清除内存\npublic native void freeMemory(long address);\n
使用下面的代码进行测试:
\nprivate void memoryTest() {\n int size = 4;\n long addr = unsafe.allocateMemory(size);\n long addr3 = unsafe.reallocateMemory(addr, size * 2);\n System.out.println(\"addr: \"+addr);\n System.out.println(\"addr3: \"+addr3);\n try {\n unsafe.setMemory(null,addr ,size,(byte)1);\n for (int i = 0; i < 2; i++) {\n unsafe.copyMemory(null,addr,null,addr3+size*i,4);\n }\n System.out.println(unsafe.getInt(addr));\n System.out.println(unsafe.getLong(addr3));\n }finally {\n unsafe.freeMemory(addr);\n unsafe.freeMemory(addr3);\n }\n}\n
先看结果输出:
\naddr: 2433733895744\naddr3: 2433733894944\n16843009\n72340172838076673\n
分析一下运行结果,首先使用allocateMemory
方法申请 4 字节长度的内存空间,调用setMemory
方法向每个字节写入内容为byte
类型的 1,当使用 Unsafe 调用getInt
方法时,因为一个int
型变量占 4 个字节,会一次性读取 4 个字节,组成一个int
的值,对应的十进制结果为 16843009。
你可以通过下图理解这个过程:
\n\n在代码中调用reallocateMemory
方法重新分配了一块 8 字节长度的内存空间,通过比较addr
和addr3
可以看到和之前申请的内存地址是不同的。在代码中的第二个 for 循环里,调用copyMemory
方法进行了两次内存的拷贝,每次拷贝内存地址addr
开始的 4 个字节,分别拷贝到以addr3
和addr3+4
开始的内存空间上:
拷贝完成后,使用getLong
方法一次性读取 8 个字节,得到long
类型的值为 72340172838076673。
需要注意,通过这种方式分配的内存属于 堆外内存 ,是无法进行垃圾回收的,需要我们把这些内存当做一种资源去手动调用freeMemory
方法进行释放,否则会产生内存泄漏。通用的操作内存方式是在try
中执行对内存的操作,最终在finally
块中进行内存的释放。
为什么要使用堆外内存?
\nDirectByteBuffer
是 Java 用于实现堆外内存的一个重要类,通常用在通信过程中做缓冲池,如在 Netty、MINA 等 NIO 框架中应用广泛。DirectByteBuffer
对于堆外内存的创建、使用、销毁等逻辑均由 Unsafe 提供的堆外内存 API 来实现。
下图为 DirectByteBuffer
构造函数,创建 DirectByteBuffer
的时候,通过 Unsafe.allocateMemory
分配内存、Unsafe.setMemory
进行内存初始化,而后构建 Cleaner
对象用于跟踪 DirectByteBuffer
对象的垃圾回收,以实现当 DirectByteBuffer
被垃圾回收时,分配的堆外内存一起被释放。
DirectByteBuffer(int cap) { // package-private\n\n super(-1, 0, cap, cap);\n boolean pa = VM.isDirectMemoryPageAligned();\n int ps = Bits.pageSize();\n long size = Math.max(1L, (long)cap + (pa ? ps : 0));\n Bits.reserveMemory(size, cap);\n\n long base = 0;\n try {\n // 分配内存并返回基地址\n base = unsafe.allocateMemory(size);\n } catch (OutOfMemoryError x) {\n Bits.unreserveMemory(size, cap);\n throw x;\n }\n // 内存初始化\n unsafe.setMemory(base, size, (byte) 0);\n if (pa && (base % ps != 0)) {\n // Round up to page boundary\n address = base + ps - (base & (ps - 1));\n } else {\n address = base;\n }\n // 跟踪 DirectByteBuffer 对象的垃圾回收,以实现堆外内存释放\n cleaner = Cleaner.create(this, new Deallocator(base, size, cap));\n att = null;\n}\n
在介绍内存屏障前,需要知道编译器和 CPU 会在保证程序输出结果一致的情况下,会对代码进行重排序,从指令优化角度提升性能。而指令重排序可能会带来一个不好的结果,导致 CPU 的高速缓存和内存中数据的不一致,而内存屏障(Memory Barrier
)就是通过阻止屏障两边的指令重排序从而避免编译器和硬件的不正确优化情况。
在硬件层面上,内存屏障是 CPU 为了防止代码进行重排序而提供的指令,不同的硬件平台上实现内存屏障的方法可能并不相同。在 Java8 中,引入了 3 个内存屏障的函数,它屏蔽了操作系统底层的差异,允许在代码中定义、并统一由 JVM 来生成内存屏障指令,来实现内存屏障的功能。
\nUnsafe
中提供了下面三个内存屏障相关方法:
//内存屏障,禁止load操作重排序。屏障前的load操作不能被重排序到屏障后,屏障后的load操作不能被重排序到屏障前\npublic native void loadFence();\n//内存屏障,禁止store操作重排序。屏障前的store操作不能被重排序到屏障后,屏障后的store操作不能被重排序到屏障前\npublic native void storeFence();\n//内存屏障,禁止load、store操作重排序\npublic native void fullFence();\n
内存屏障可以看做对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。以loadFence
方法为例,它会禁止读操作重排序,保证在这个屏障之前的所有读操作都已经完成,并且将缓存数据设为无效,重新从主存中进行加载。
看到这估计很多小伙伴们会想到volatile
关键字了,如果在字段上添加了volatile
关键字,就能够实现字段在多线程下的可见性。基于读内存屏障,我们也能实现相同的功能。下面定义一个线程方法,在线程中去修改flag
标志位,注意这里的flag
是没有被volatile
修饰的:
@Getter\nclass ChangeThread implements Runnable{\n /**volatile**/ boolean flag=false;\n @Override\n public void run() {\n try {\n Thread.sleep(3000);\n } catch (InterruptedException e) {\n e.printStackTrace();\n }\n System.out.println(\"subThread change flag to:\" + flag);\n flag = true;\n }\n}\n
在主线程的while
循环中,加入内存屏障,测试是否能够感知到flag
的修改变化:
public static void main(String[] args){\n ChangeThread changeThread = new ChangeThread();\n new Thread(changeThread).start();\n while (true) {\n boolean flag = changeThread.isFlag();\n unsafe.loadFence(); //加入读内存屏障\n if (flag){\n System.out.println(\"detected flag changed\");\n break;\n }\n }\n System.out.println(\"main thread end\");\n}\n
运行结果:
\nsubThread change flag to:false\ndetected flag changed\nmain thread end\n
而如果删掉上面代码中的loadFence
方法,那么主线程将无法感知到flag
发生的变化,会一直在while
中循环。可以用图来表示上面的过程:
了解 Java 内存模型(JMM
)的小伙伴们应该清楚,运行中的线程不是直接读取主内存中的变量的,只能操作自己工作内存中的变量,然后同步到主内存中,并且线程的工作内存是不能共享的。上面的图中的流程就是子线程借助于主内存,将修改后的结果同步给了主线程,进而修改主线程中的工作空间,跳出循环。
在 Java 8 中引入了一种锁的新机制——StampedLock
,它可以看成是读写锁的一个改进版本。StampedLock
提供了一种乐观读锁的实现,这种乐观读锁类似于无锁的操作,完全不会阻塞写线程获取写锁,从而缓解读多写少时写线程“饥饿”现象。由于 StampedLock
提供的乐观读锁不阻塞写线程获取读锁,当线程共享变量从主内存 load 到线程工作内存时,会存在数据不一致问题。
为了解决这个问题,StampedLock
的 validate
方法会通过 Unsafe
的 loadFence
方法加入一个 load
内存屏障。
public boolean validate(long stamp) {\n U.loadFence();\n return (stamp & SBITS) == (state & SBITS);\n}\n
例子
\nimport sun.misc.Unsafe;\nimport java.lang.reflect.Field;\n\npublic class Main {\n\n private int value;\n\n public static void main(String[] args) throws Exception{\n Unsafe unsafe = reflectGetUnsafe();\n assert unsafe != null;\n long offset = unsafe.objectFieldOffset(Main.class.getDeclaredField(\"value\"));\n Main main = new Main();\n System.out.println(\"value before putInt: \" + main.value);\n unsafe.putInt(main, offset, 42);\n System.out.println(\"value after putInt: \" + main.value);\n System.out.println(\"value after putInt: \" + unsafe.getInt(main, offset));\n }\n\n private static Unsafe reflectGetUnsafe() {\n try {\n Field field = Unsafe.class.getDeclaredField(\"theUnsafe\");\n field.setAccessible(true);\n return (Unsafe) field.get(null);\n } catch (Exception e) {\n e.printStackTrace();\n return null;\n }\n }\n\n}\n
输出结果:
\nvalue before putInt: 0\nvalue after putInt: 42\nvalue after putInt: 42\n
对象属性
\n对象成员属性的内存偏移量获取,以及字段属性值的修改,在上面的例子中我们已经测试过了。除了前面的putInt
、getInt
方法外,Unsafe 提供了全部 8 种基础数据类型以及Object
的put
和get
方法,并且所有的put
方法都可以越过访问权限,直接修改内存中的数据。阅读 openJDK 源码中的注释发现,基础数据类型和Object
的读写稍有不同,基础数据类型是直接操作的属性值(value
),而Object
的操作则是基于引用值(reference value
)。下面是Object
的读写方法:
//在对象的指定偏移地址获取一个对象引用\npublic native Object getObject(Object o, long offset);\n//在对象指定偏移地址写入一个对象引用\npublic native void putObject(Object o, long offset, Object x);\n
除了对象属性的普通读写外,Unsafe
还提供了 volatile 读写和有序写入方法。volatile
读写方法的覆盖范围与普通读写相同,包含了全部基础数据类型和Object
类型,以int
类型为例:
//在对象的指定偏移地址处读取一个int值,支持volatile load语义\npublic native int getIntVolatile(Object o, long offset);\n//在对象指定偏移地址处写入一个int,支持volatile store语义\npublic native void putIntVolatile(Object o, long offset, int x);\n
相对于普通读写来说,volatile
读写具有更高的成本,因为它需要保证可见性和有序性。在执行get
操作时,会强制从主存中获取属性值,在使用put
方法设置属性值时,会强制将值更新到主存中,从而保证这些变更对其他线程是可见的。
有序写入的方法有以下三个:
\npublic native void putOrderedObject(Object o, long offset, Object x);\npublic native void putOrderedInt(Object o, long offset, int x);\npublic native void putOrderedLong(Object o, long offset, long x);\n
有序写入的成本相对volatile
较低,因为它只保证写入时的有序性,而不保证可见性,也就是一个线程写入的值不能保证其他线程立即可见。为了解决这里的差异性,需要对内存屏障的知识点再进一步进行补充,首先需要了解两个指令的概念:
Load
:将主内存中的数据拷贝到处理器的缓存中Store
:将处理器缓存的数据刷新到主内存中顺序写入与volatile
写入的差别在于,在顺序写时加入的内存屏障类型为StoreStore
类型,而在volatile
写入时加入的内存屏障是StoreLoad
类型,如下图所示:
在有序写入方法中,使用的是StoreStore
屏障,该屏障确保Store1
立刻刷新数据到内存,这一操作先于Store2
以及后续的存储指令操作。而在volatile
写入中,使用的是StoreLoad
屏障,该屏障确保Store1
立刻刷新数据到内存,这一操作先于Load2
及后续的装载指令,并且,StoreLoad
屏障会使该屏障之前的所有内存访问指令,包括存储指令和访问指令全部完成之后,才执行该屏障之后的内存访问指令。
综上所述,在上面的三类写入方法中,在写入效率方面,按照put
、putOrder
、putVolatile
的顺序效率逐渐降低。
对象实例化
\n使用 Unsafe
的 allocateInstance
方法,允许我们使用非常规的方式进行对象的实例化,首先定义一个实体类,并且在构造函数中对其成员变量进行赋值操作:
@Data\npublic class A {\n private int b;\n public A(){\n this.b =1;\n }\n}\n
分别基于构造函数、反射以及 Unsafe
方法的不同方式创建对象进行比较:
public void objTest() throws Exception{\n A a1=new A();\n System.out.println(a1.getB());\n A a2 = A.class.newInstance();\n System.out.println(a2.getB());\n A a3= (A) unsafe.allocateInstance(A.class);\n System.out.println(a3.getB());\n}\n
打印结果分别为 1、1、0,说明通过allocateInstance
方法创建对象过程中,不会调用类的构造方法。使用这种方式创建对象时,只用到了Class
对象,所以说如果想要跳过对象的初始化阶段或者跳过构造器的安全检查,就可以使用这种方法。在上面的例子中,如果将 A 类的构造函数改为private
类型,将无法通过构造函数和反射创建对象(可以通过构造函数对象 setAccessible 后创建对象),但allocateInstance
方法仍然有效。
arrayBaseOffset
与 arrayIndexScale
这两个方法配合起来使用,即可定位数组中每个元素在内存中的位置。
//返回数组中第一个元素的偏移地址\npublic native int arrayBaseOffset(Class<?> arrayClass);\n//返回数组中一个元素占用的大小\npublic native int arrayIndexScale(Class<?> arrayClass);\n
这两个与数据操作相关的方法,在 java.util.concurrent.atomic
包下的 AtomicIntegerArray
(可以实现对 Integer
数组中每个元素的原子性操作)中有典型的应用,如下图 AtomicIntegerArray
源码所示,通过 Unsafe
的 arrayBaseOffset
、arrayIndexScale
分别获取数组首元素的偏移地址 base
及单个元素大小因子 scale
。后续相关原子性操作,均依赖于这两个值进行数组中元素的定位,如下图二所示的 getAndAdd
方法即通过 checkedByteOffset
方法获取某数组元素的偏移地址,而后通过 CAS 实现原子性操作。
这部分主要为 CAS 相关操作的方法。
\n/**\n * CAS\n * @param o 包含要修改field的对象\n * @param offset 对象中某field的偏移量\n * @param expected 期望值\n * @param update 更新值\n * @return true | false\n */\npublic final native boolean compareAndSwapObject(Object o, long offset, Object expected, Object update);\n\npublic final native boolean compareAndSwapInt(Object o, long offset, int expected,int update);\n\npublic final native boolean compareAndSwapLong(Object o, long offset, long expected, long update);\n
什么是 CAS? CAS 即比较并替换(Compare And Swap),是实现并发算法时常用到的一种技术。CAS 操作包含三个操作数——内存位置、预期原值及新值。执行 CAS 操作的时候,将内存位置的值与预期原值比较,如果相匹配,那么处理器会自动将该位置值更新为新值,否则,处理器不做任何操作。我们都知道,CAS 是一条 CPU 的原子指令(cmpxchg 指令),不会造成所谓的数据不一致问题,Unsafe
提供的 CAS 方法(如 compareAndSwapXXX
)底层实现即为 CPU 指令 cmpxchg
。
在 JUC 包的并发工具类中大量地使用了 CAS 操作,像在前面介绍synchronized
和AQS
的文章中也多次提到了 CAS,其作为乐观锁在并发工具类中广泛发挥了作用。在 Unsafe
类中,提供了compareAndSwapObject
、compareAndSwapInt
、compareAndSwapLong
方法来实现的对Object
、int
、long
类型的 CAS 操作。以compareAndSwapInt
方法为例:
public final native boolean compareAndSwapInt(Object o, long offset,int expected,int x);\n
参数中o
为需要更新的对象,offset
是对象o
中整形字段的偏移量,如果这个字段的值与expected
相同,则将字段的值设为x
这个新值,并且此更新是不可被中断的,也就是一个原子操作。下面是一个使用compareAndSwapInt
的例子:
private volatile int a;\npublic static void main(String[] args){\n CasTest casTest=new CasTest();\n new Thread(()->{\n for (int i = 1; i < 5; i++) {\n casTest.increment(i);\n System.out.print(casTest.a+\" \");\n }\n }).start();\n new Thread(()->{\n for (int i = 5 ; i <10 ; i++) {\n casTest.increment(i);\n System.out.print(casTest.a+\" \");\n }\n }).start();\n}\n\nprivate void increment(int x){\n while (true){\n try {\n long fieldOffset = unsafe.objectFieldOffset(CasTest.class.getDeclaredField(\"a\"));\n if (unsafe.compareAndSwapInt(this,fieldOffset,x-1,x))\n break;\n } catch (NoSuchFieldException e) {\n e.printStackTrace();\n }\n }\n}\n
运行代码会依次输出:
\n1 2 3 4 5 6 7 8 9\n
在上面的例子中,使用两个线程去修改int
型属性a
的值,并且只有在a
的值等于传入的参数x
减一时,才会将a
的值变为x
,也就是实现对a
的加一的操作。流程如下所示:
需要注意的是,在调用compareAndSwapInt
方法后,会直接返回true
或false
的修改结果,因此需要我们在代码中手动添加自旋的逻辑。在AtomicInteger
类的设计中,也是采用了将compareAndSwapInt
的结果作为循环条件,直至修改成功才退出死循环的方式来实现的原子性的自增操作。
Unsafe
类中提供了park
、unpark
、monitorEnter
、monitorExit
、tryMonitorEnter
方法进行线程调度。
//取消阻塞线程\npublic native void unpark(Object thread);\n//阻塞线程\npublic native void park(boolean isAbsolute, long time);\n//获得对象锁(可重入锁)\n@Deprecated\npublic native void monitorEnter(Object o);\n//释放对象锁\n@Deprecated\npublic native void monitorExit(Object o);\n//尝试获取对象锁\n@Deprecated\npublic native boolean tryMonitorEnter(Object o);\n
方法 park
、unpark
即可实现线程的挂起与恢复,将一个线程进行挂起是通过 park
方法实现的,调用 park
方法后,线程将一直阻塞直到超时或者中断等条件出现;unpark
可以终止一个挂起的线程,使其恢复正常。
此外,Unsafe
源码中monitor
相关的三个方法已经被标记为deprecated
,不建议被使用:
//获得对象锁\n@Deprecated\npublic native void monitorEnter(Object var1);\n//释放对象锁\n@Deprecated\npublic native void monitorExit(Object var1);\n//尝试获得对象锁\n@Deprecated\npublic native boolean tryMonitorEnter(Object var1);\n
monitorEnter
方法用于获得对象锁,monitorExit
用于释放对象锁,如果对一个没有被monitorEnter
加锁的对象执行此方法,会抛出IllegalMonitorStateException
异常。tryMonitorEnter
方法尝试获取对象锁,如果成功则返回true
,反之返回false
。
Java 锁和同步器框架的核心类 AbstractQueuedSynchronizer
(AQS),就是通过调用LockSupport.park()
和LockSupport.unpark()
实现线程的阻塞和唤醒的,而 LockSupport
的 park
、unpark
方法实际是调用 Unsafe
的 park
、unpark
方式实现的。
public static void park(Object blocker) {\n Thread t = Thread.currentThread();\n setBlocker(t, blocker);\n UNSAFE.park(false, 0L);\n setBlocker(t, null);\n}\npublic static void unpark(Thread thread) {\n if (thread != null)\n UNSAFE.unpark(thread);\n}\n
LockSupport
的park
方法调用了 Unsafe
的park
方法来阻塞当前线程,此方法将线程阻塞后就不会继续往后执行,直到有其他线程调用unpark
方法唤醒当前线程。下面的例子对 Unsafe
的这两个方法进行测试:
public static void main(String[] args) {\n Thread mainThread = Thread.currentThread();\n new Thread(()->{\n try {\n TimeUnit.SECONDS.sleep(5);\n System.out.println(\"subThread try to unpark mainThread\");\n unsafe.unpark(mainThread);\n } catch (InterruptedException e) {\n e.printStackTrace();\n }\n }).start();\n\n System.out.println(\"park main mainThread\");\n unsafe.park(false,0L);\n System.out.println(\"unpark mainThread success\");\n}\n
程序输出为:
\npark main mainThread\nsubThread try to unpark mainThread\nunpark mainThread success\n
程序运行的流程也比较容易看懂,子线程开始运行后先进行睡眠,确保主线程能够调用park
方法阻塞自己,子线程在睡眠 5 秒后,调用unpark
方法唤醒主线程,使主线程能继续向下执行。整个流程如下图所示:
Unsafe
对Class
的相关操作主要包括类加载和静态变量的操作方法。
静态属性读取相关的方法
\n//获取静态属性的偏移量\npublic native long staticFieldOffset(Field f);\n//获取静态属性的对象指针\npublic native Object staticFieldBase(Field f);\n//判断类是否需要初始化(用于获取类的静态属性前进行检测)\npublic native boolean shouldBeInitialized(Class<?> c);\n
创建一个包含静态属性的类,进行测试:
\n@Data\npublic class User {\n public static String name=\"Hydra\";\n int age;\n}\nprivate void staticTest() throws Exception {\n User user=new User();\n // 也可以用下面的语句触发类初始化\n // 1.\n // unsafe.ensureClassInitialized(User.class);\n // 2.\n // System.out.println(User.name);\n System.out.println(unsafe.shouldBeInitialized(User.class));\n Field sexField = User.class.getDeclaredField(\"name\");\n long fieldOffset = unsafe.staticFieldOffset(sexField);\n Object fieldBase = unsafe.staticFieldBase(sexField);\n Object object = unsafe.getObject(fieldBase, fieldOffset);\n System.out.println(object);\n}\n
运行结果:
\nfalse\nHydra\n
在 Unsafe
的对象操作中,我们学习了通过objectFieldOffset
方法获取对象属性偏移量并基于它对变量的值进行存取,但是它不适用于类中的静态属性,这时候就需要使用staticFieldOffset
方法。在上面的代码中,只有在获取Field
对象的过程中依赖到了Class
,而获取静态变量的属性时不再依赖于Class
。
在上面的代码中首先创建一个User
对象,这是因为如果一个类没有被初始化,那么它的静态属性也不会被初始化,最后获取的字段属性将是null
。所以在获取静态属性前,需要调用shouldBeInitialized
方法,判断在获取前是否需要初始化这个类。如果删除创建 User 对象的语句,运行结果会变为:
true\nnull\n
使用defineClass
方法允许程序在运行时动态地创建一个类
public native Class<?> defineClass(String name, byte[] b, int off, int len, ClassLoader loader,ProtectionDomain protectionDomain);\n
在实际使用过程中,可以只传入字节数组、起始字节的下标以及读取的字节长度,默认情况下,类加载器(ClassLoader
)和保护域(ProtectionDomain
)来源于调用此方法的实例。下面的例子中实现了反编译生成后的 class 文件的功能:
private static void defineTest() {\n String fileName=\"F:\\\\workspace\\\\unsafe-test\\\\target\\\\classes\\\\com\\\\cn\\\\model\\\\User.class\";\n File file = new File(fileName);\n try(FileInputStream fis = new FileInputStream(file)) {\n byte[] content=new byte[(int)file.length()];\n fis.read(content);\n Class clazz = unsafe.defineClass(null, content, 0, content.length, null, null);\n Object o = clazz.newInstance();\n Object age = clazz.getMethod(\"getAge\").invoke(o, null);\n System.out.println(age);\n } catch (Exception e) {\n e.printStackTrace();\n }\n}\n
在上面的代码中,首先读取了一个class
文件并通过文件流将它转化为字节数组,之后使用defineClass
方法动态的创建了一个类,并在后续完成了它的实例化工作,流程如下图所示,并且通过这种方式创建的类,会跳过 JVM 的所有安全检查。
除了defineClass
方法外,Unsafe 还提供了一个defineAnonymousClass
方法:
public native Class<?> defineAnonymousClass(Class<?> hostClass, byte[] data, Object[] cpPatches);\n
使用该方法可以用来动态的创建一个匿名类,在Lambda
表达式中就是使用 ASM 动态生成字节码,然后利用该方法定义实现相应的函数式接口的匿名类。在 JDK 15 发布的新特性中,在隐藏类(Hidden classes
)一条中,指出将在未来的版本中弃用 Unsafe
的defineAnonymousClass
方法。
Lambda 表达式实现需要依赖 Unsafe
的 defineAnonymousClass
方法定义实现相应的函数式接口的匿名类。
这部分包含两个获取系统相关信息的方法。
\n//返回系统指针的大小。返回值为4(32位系统)或 8(64位系统)。\npublic native int addressSize();\n//内存页的大小,此值为2的幂次方。\npublic native int pageSize();\n
这两个方法的应用场景比较少,在java.nio.Bits
类中,在使用pageCount
计算所需的内存页的数量时,调用了pageSize
方法获取内存页的大小。另外,在使用copySwapMemory
方法拷贝内存时,调用了addressSize
方法,检测 32 位系统的情况。
在本文中,我们首先介绍了 Unsafe
的基本概念、工作原理,并在此基础上,对它的 API 进行了说明与实践。相信大家通过这一过程,能够发现 Unsafe
在某些场景下,确实能够为我们提供编程中的便利。但是回到开头的话题,在使用这些便利时,确实存在着一些安全上的隐患,在我看来,一项技术具有不安全因素并不可怕,可怕的是它在使用过程中被滥用。尽管之前有传言说会在 Java9 中移除 Unsafe
类,不过它还是照样已经存活到了 Java16。按照存在即合理的逻辑,只要使用得当,它还是能给我们带来不少的帮助,因此最后还是建议大家,在使用 Unsafe
的过程中一定要做到使用谨慎使用、避免滥用。
\n\n本文重构完善自谈谈为什么写单元测试 - 键盘男 - 2016这篇文章。
\n
维基百科是这样介绍单元测试的:
\n\n\n在计算机编程中,单元测试(Unit Testing)是针对程序模块(软件设计的最小单位)进行的正确性检验测试工作。
\n程序单元是应用的 最小可测试部件 。在过程化编程中,一个单元就是单个程序、函数、过程等;对于面向对象编程,最小单元就是方法,包括基类(超类)、抽象类、或者派生类(子类)中的方法。
\n
由于每个单元有独立的逻辑,在做单元测试时,为了隔离外部依赖,确保这些依赖不影响验证逻辑,我们经常会用到 Fake、Stub 与 Mock 。
\n关于 Fake、Mock 与 Stub 这几个概念的解读,可以看看这篇文章:测试中 Fakes、Mocks 以及 Stubs 概念明晰 - 王下邀月熊 - 2018 。
\n我在重构这篇文章中这样写到:
\n\n\n单元测试可以为重构提供信心,降低重构的成本。我们要像重视生产代码那样,重视单元测试。
\n
每个开发者都会经历重构,重构后把代码改坏了的情况并不少见,很可能你只是修改了一个很简单的方法就导致系统出现了一个比较严重的错误。
\n如果有了单元测试的话,就不会存在这个隐患了。写完一个类,把单元测试写了,确保这个类逻辑正确;写第二个类,单元测试……写 100 个类,道理一样,每个类做到第一点“保证逻辑正确性”,100 个类拼在一起肯定不出问题。你大可以放心一边重构,一边运行 APP;而不是整体重构完,提心吊胆地 run。
\n由于每个单元有独立的逻辑,做单元测试时需要隔离外部依赖,确保这些依赖不影响验证逻辑。因为要把各种依赖分离,单元测试会促进工程进行组件拆分,整理工程依赖关系,更大程度减少代码耦合。这样写出来的代码,更好维护,更好扩展,从而提高代码质量。
\n一个机器,由各种细小的零件组成,如果其中某件零件坏了,机器运行故障。必须保证每个零件都按设计图要求的规格,机器才能正常运行。
\n一个可单元测试的工程,会把业务、功能分割成规模更小、有独立的逻辑部件,称为单元。单元测试的目标,就是保证各个单元的逻辑正确性。单元测试保障工程各个“零件”按“规格”(需求)执行,从而保证整个“机器”(项目)运行正确,最大限度减少 bug。
\n如果程序有 bug,我们运行一次全部单元测试,找到不通过的测试,可以很快地定位对应的执行代码。修复代码后,运行对应的单元测试;如还不通过,继续修改,运行测试……直到测试通过。
\n持续集成需要依赖单元测试,当持续集成服务自动构建新代码之后,会自动运行单元测试来发现代码错误。
\n有些经验丰富的领导,或多或少都会要求团队写单元测试。对于有一定工作经验的队友,这要求挺合理;对于经验尚浅的、毕业生,恐怕要死要活了,连代码都写不好,还要写单元测试,are you kidding me?
\n培训新人单元测试用法,是一项艰巨的任务。新人代码风格未形成,也不知道单元测试多重要,强制单元测试会让他们感到困惑,没办法按自己思路写代码。
\n国外很多家喻户晓的开源项目,都有大量单元测试。例如,retrofit、okhttp、butterknife…… 国外大牛都写单元测试,我们也写吧!
\n很多读者都有这种想法,一开始满腔热血。当真要对自己项目单元测试时,便困难重重,很大原因是项目对单元测试不友好。最后只能对一些不痛不痒的工具类做单元测试,久而久之,当初美好愿望也不了了之。
\n都是有些许年经验的老鸟,还天天被测试同学追 bug,好意思么?花多一点时间写单元测试,确保没低级 bug,还能彰显大牛风范,何乐而不为?
\n笔者也是个不太相信自己代码的人,总觉得哪里会突然冒出莫名其妙的 bug,也怕别人不小心改了自己的代码(被害妄想症),新版本上线提心吊胆……花点时间写单元测试,有事没事跑一下测试,确保原逻辑没问题,至少能睡安稳一点。
\nTDD 即 Test-Driven Development( 测试驱动开发),这是敏捷开发的一项核心实践和技术,也是一种设计方法论。
\nTDD 原理是开发功能代码之前,先编写测试用例代码,然后针对测试用例编写功能代码,使其能够通过。
\nTDD 的节奏:“红 - 绿 - 重构”。
\n\n由于 TDD 对开发人员要求非常高,跟传统开发思维不一样,因此实施起来相当困难。
\nTDD 在很多人眼中是不实用的,一来他们并不理解测试“驱动”开发的含义,但更重要的是,他们很少会做任务分解。而任务分解是做好 TDD 的关键点。只有把任务分解到可以测试的地步,才能够有针对性地写测试。
\n测试驱动开发有好处也有坏处。因为每个测试用例都是根据需求来的,或者说把一个大需求分解成若干小需求编写测试用例,所以测试用例写出来后,开发者写的执行代码,必须满足测试用例。如果测试不通过,则修改执行代码,直到测试用例通过。
\n优点:
\n缺点:
\n相关阅读:如何用正确的姿势打开 TDD? - 陈天 - 2017 。
\n对于单测来说,目前常用的单测框架有:JUnit、Mockito、Spock、PowerMock、JMockit、TestableMock 等等。
\nJUnit 几乎是默认选择,但是其不支持 Mock,因此我们还需要选择一个 Mock 工具。Mockito 和 Spock 是最主流的两款 Mock 工具,一般都是在这两者中选择。
\n究竟是选择 Mockito 还是 Spock 呢?我这里做了一些简单的对比分析:
\nMockito 和 Spock 都是非常不错的 Mock 工具,相对来说,Mockito 的适用性更强一些。
\n单元测试确实会带给你相当多的好处,但不是立刻体验出来。正如买重疾保险,交了很多保费,没病没痛,十几年甚至几十年都用不上,最好就是一辈子用不上理赔,身体健康最重要。单元测试也一样,写了可以买个放心,对代码的一种保障,有 bug 尽快测出来,没 bug 就最好,总不能说“写那么多单元测试,结果测不出 bug,浪费时间”吧?
\n以下是个人对单元测试一些建议:
\n\n\n\n
\n- 越重要的代码,越要写单元测试;
\n- 代码做不到单元测试,多思考如何改进,而不是放弃;
\n- 边写业务代码,边写单元测试,而不是完成整个新功能后再写;
\n- 多思考如何改进、简化测试代码。
\n- 测试代码需要随着生产代码的演进而重构或者修改,如果测试不能保持整洁,只会越来越难修改。
\n
作为一名经验丰富的程序员,写单元测试更多的是对自己的代码负责。有测试用例的代码,别人更容易看懂,以后别人接手你的代码时,也可能放心做改动。
\n多敲代码实践,多跟有单元测试经验的工程师交流,你会发现写单元测试获得的收益会更多。
\n\n", "image": "https://static001.geekbang.org/resource/image/09/7f/090e1fc6aff08b4aa66376f776c2337f.png", "date_published": "2022-07-16T13:03:16.000Z", "date_modified": "2023-10-26T22:44:02.000Z", "authors": [], "tags": [ "代码质量" ] }, { "title": "Java IO 基础知识总结", "url": "https://javaguide.cn/java/io/io-basis.html", "id": "https://javaguide.cn/java/io/io-basis.html", "summary": " 这是一则或许对你有用的小广告 面试专版:准备 Java 面试的小伙伴可以考虑面试专版: (质量非常高,专为面试打造,配合 JavaGuide 食用效果最佳)。 知识星球:技术专栏/一对一提问/简历修改/求职指南/面试打卡/不定时福利,欢迎加入 。 IO 流简介 IO 即 Input/Output,输入和输出。数据输入到计算机内存的过程即输入,反之输出...", "content_html": "这是一则或许对你有用的小广告
\nIO 即 Input/Output
,输入和输出。数据输入到计算机内存的过程即输入,反之输出到外部存储(比如数据库,文件,远程主机)的过程即输出。数据传输过程类似于水流,因此称为 IO 流。IO 流在 Java 中分为输入流和输出流,而根据数据的处理方式又分为字节流和字符流。
Java IO 流的 40 多个类都是从如下 4 个抽象类基类中派生出来的。
\nInputStream
/Reader
: 所有的输入流的基类,前者是字节输入流,后者是字符输入流。OutputStream
/Writer
: 所有输出流的基类,前者是字节输出流,后者是字符输出流。InputStream
用于从源头(通常是文件)读取数据(字节信息)到内存中,java.io.InputStream
抽象类是所有字节输入流的父类。
InputStream
常用方法:
read()
:返回输入流中下一个字节的数据。返回的值介于 0 到 255 之间。如果未读取任何字节,则代码返回 -1
,表示文件结束。read(byte b[ ])
: 从输入流中读取一些字节存储到数组 b
中。如果数组 b
的长度为零,则不读取。如果没有可用字节读取,返回 -1
。如果有可用字节读取,则最多读取的字节数最多等于 b.length
, 返回读取的字节数。这个方法等价于 read(b, 0, b.length)
。read(byte b[], int off, int len)
:在read(byte b[ ])
方法的基础上增加了 off
参数(偏移量)和 len
参数(要读取的最大字节数)。skip(long n)
:忽略输入流中的 n 个字节 ,返回实际忽略的字节数。available()
:返回输入流中可以读取的字节数。close()
:关闭输入流释放相关的系统资源。从 Java 9 开始,InputStream
新增加了多个实用的方法:
readAllBytes()
:读取输入流中的所有字节,返回字节数组。readNBytes(byte[] b, int off, int len)
:阻塞直到读取 len
个字节。transferTo(OutputStream out)
:将所有字节从一个输入流传递到一个输出流。FileInputStream
是一个比较常用的字节输入流对象,可直接指定文件路径,可以直接读取单字节数据,也可以读取至字节数组中。
FileInputStream
代码示例:
try (InputStream fis = new FileInputStream(\"input.txt\")) {\n System.out.println(\"Number of remaining bytes:\"\n + fis.available());\n int content;\n long skip = fis.skip(2);\n System.out.println(\"The actual number of bytes skipped:\" + skip);\n System.out.print(\"The content read from file:\");\n while ((content = fis.read()) != -1) {\n System.out.print((char) content);\n }\n} catch (IOException e) {\n e.printStackTrace();\n}\n
input.txt
文件内容:
输出:
\nNumber of remaining bytes:11\nThe actual number of bytes skipped:2\nThe content read from file:JavaGuide\n
不过,一般我们是不会直接单独使用 FileInputStream
,通常会配合 BufferedInputStream
(字节缓冲输入流,后文会讲到)来使用。
像下面这段代码在我们的项目中就比较常见,我们通过 readAllBytes()
读取输入流所有字节并将其直接赋值给一个 String
对象。
// 新建一个 BufferedInputStream 对象\nBufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream(\"input.txt\"));\n// 读取文件的内容并复制到 String 对象中\nString result = new String(bufferedInputStream.readAllBytes());\nSystem.out.println(result);\n
DataInputStream
用于读取指定类型数据,不能单独使用,必须结合其它流,比如 FileInputStream
。
FileInputStream fileInputStream = new FileInputStream(\"input.txt\");\n//必须将fileInputStream作为构造参数才能使用\nDataInputStream dataInputStream = new DataInputStream(fileInputStream);\n//可以读取任意具体的类型数据\ndataInputStream.readBoolean();\ndataInputStream.readInt();\ndataInputStream.readUTF();\n
ObjectInputStream
用于从输入流中读取 Java 对象(反序列化),ObjectOutputStream
用于将对象写入到输出流(序列化)。
ObjectInputStream input = new ObjectInputStream(new FileInputStream(\"object.data\"));\nMyClass object = (MyClass) input.readObject();\ninput.close();\n
另外,用于序列化和反序列化的类必须实现 Serializable
接口,对象中如果有属性不想被序列化,使用 transient
修饰。
OutputStream
用于将数据(字节信息)写入到目的地(通常是文件),java.io.OutputStream
抽象类是所有字节输出流的父类。
OutputStream
常用方法:
write(int b)
:将特定字节写入输出流。write(byte b[ ])
: 将数组b
写入到输出流,等价于 write(b, 0, b.length)
。write(byte[] b, int off, int len)
: 在write(byte b[ ])
方法的基础上增加了 off
参数(偏移量)和 len
参数(要读取的最大字节数)。flush()
:刷新此输出流并强制写出所有缓冲的输出字节。close()
:关闭输出流释放相关的系统资源。FileOutputStream
是最常用的字节输出流对象,可直接指定文件路径,可以直接输出单字节数据,也可以输出指定的字节数组。
FileOutputStream
代码示例:
try (FileOutputStream output = new FileOutputStream(\"output.txt\")) {\n byte[] array = \"JavaGuide\".getBytes();\n output.write(array);\n} catch (IOException e) {\n e.printStackTrace();\n}\n
运行结果:
\n\n类似于 FileInputStream
,FileOutputStream
通常也会配合 BufferedOutputStream
(字节缓冲输出流,后文会讲到)来使用。
FileOutputStream fileOutputStream = new FileOutputStream(\"output.txt\");\nBufferedOutputStream bos = new BufferedOutputStream(fileOutputStream)\n
DataOutputStream
用于写入指定类型数据,不能单独使用,必须结合其它流,比如 FileOutputStream
。
// 输出流\nFileOutputStream fileOutputStream = new FileOutputStream(\"out.txt\");\nDataOutputStream dataOutputStream = new DataOutputStream(fileOutputStream);\n// 输出任意数据类型\ndataOutputStream.writeBoolean(true);\ndataOutputStream.writeByte(1);\n
ObjectInputStream
用于从输入流中读取 Java 对象(ObjectInputStream
,反序列化),ObjectOutputStream
将对象写入到输出流(ObjectOutputStream
,序列化)。
ObjectOutputStream output = new ObjectOutputStream(new FileOutputStream(\"file.txt\")\nPerson person = new Person(\"Guide哥\", \"JavaGuide作者\");\noutput.writeObject(person);\n
不管是文件读写还是网络发送接收,信息的最小存储单元都是字节。 那为什么 I/O 流操作要分为字节流操作和字符流操作呢?
\n个人认为主要有两点原因:
\n乱码问题这个很容易就可以复现,我们只需要将上面提到的 FileInputStream
代码示例中的 input.txt
文件内容改为中文即可,原代码不需要改动。
输出:
\nNumber of remaining bytes:9\nThe actual number of bytes skipped:2\nThe content read from file:§å®¶å¥½\n
可以很明显地看到读取出来的内容已经变成了乱码。
\n因此,I/O 流就干脆提供了一个直接操作字符的接口,方便我们平时对字符进行流操作。如果音频文件、图片等媒体文件用字节流比较好,如果涉及到字符的话使用字符流比较好。
\n字符流默认采用的是 Unicode
编码,我们可以通过构造方法自定义编码。顺便分享一下之前遇到的笔试题:常用字符编码所占字节数?utf8
:英文占 1 字节,中文占 3 字节,unicode
:任何字符都占 2 个字节,gbk
:英文占 1 字节,中文占 2 字节。
Reader
用于从源头(通常是文件)读取数据(字符信息)到内存中,java.io.Reader
抽象类是所有字符输入流的父类。
Reader
用于读取文本, InputStream
用于读取原始字节。
Reader
常用方法:
read()
: 从输入流读取一个字符。read(char[] cbuf)
: 从输入流中读取一些字符,并将它们存储到字符数组 cbuf
中,等价于 read(cbuf, 0, cbuf.length)
。read(char[] cbuf, int off, int len)
:在read(char[] cbuf)
方法的基础上增加了 off
参数(偏移量)和 len
参数(要读取的最大字符数)。skip(long n)
:忽略输入流中的 n 个字符 ,返回实际忽略的字符数。close()
: 关闭输入流并释放相关的系统资源。InputStreamReader
是字节流转换为字符流的桥梁,其子类 FileReader
是基于该基础上的封装,可以直接操作字符文件。
// 字节流转换为字符流的桥梁\npublic class InputStreamReader extends Reader {\n}\n// 用于读取字符文件\npublic class FileReader extends InputStreamReader {\n}\n
FileReader
代码示例:
try (FileReader fileReader = new FileReader(\"input.txt\");) {\n int content;\n long skip = fileReader.skip(3);\n System.out.println(\"The actual number of bytes skipped:\" + skip);\n System.out.print(\"The content read from file:\");\n while ((content = fileReader.read()) != -1) {\n System.out.print((char) content);\n }\n} catch (IOException e) {\n e.printStackTrace();\n}\n
input.txt
文件内容:
输出:
\nThe actual number of bytes skipped:3\nThe content read from file:我是Guide。\n
Writer
用于将数据(字符信息)写入到目的地(通常是文件),java.io.Writer
抽象类是所有字符输出流的父类。
Writer
常用方法:
write(int c)
: 写入单个字符。write(char[] cbuf)
:写入字符数组 cbuf
,等价于write(cbuf, 0, cbuf.length)
。write(char[] cbuf, int off, int len)
:在write(char[] cbuf)
方法的基础上增加了 off
参数(偏移量)和 len
参数(要读取的最大字符数)。write(String str)
:写入字符串,等价于 write(str, 0, str.length())
。write(String str, int off, int len)
:在write(String str)
方法的基础上增加了 off
参数(偏移量)和 len
参数(要读取的最大字符数)。append(CharSequence csq)
:将指定的字符序列附加到指定的 Writer
对象并返回该 Writer
对象。append(char c)
:将指定的字符附加到指定的 Writer
对象并返回该 Writer
对象。flush()
:刷新此输出流并强制写出所有缓冲的输出字符。close()
:关闭输出流释放相关的系统资源。OutputStreamWriter
是字符流转换为字节流的桥梁,其子类 FileWriter
是基于该基础上的封装,可以直接将字符写入到文件。
// 字符流转换为字节流的桥梁\npublic class OutputStreamWriter extends Writer {\n}\n// 用于写入字符到文件\npublic class FileWriter extends OutputStreamWriter {\n}\n
FileWriter
代码示例:
try (Writer output = new FileWriter(\"output.txt\")) {\n output.write(\"你好,我是Guide。\");\n} catch (IOException e) {\n e.printStackTrace();\n}\n
输出结果:
\n\nIO 操作是很消耗性能的,缓冲流将数据加载至缓冲区,一次性读取/写入多个字节,从而避免频繁的 IO 操作,提高流的传输效率。
\n字节缓冲流这里采用了装饰器模式来增强 InputStream
和OutputStream
子类对象的功能。
举个例子,我们可以通过 BufferedInputStream
(字节缓冲输入流)来增强 FileInputStream
的功能。
// 新建一个 BufferedInputStream 对象\nBufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream(\"input.txt\"));\n
字节流和字节缓冲流的性能差别主要体现在我们使用两者的时候都是调用 write(int b)
和 read()
这两个一次只读取一个字节的方法的时候。由于字节缓冲流内部有缓冲区(字节数组),因此,字节缓冲流会先将读取到的字节存放在缓存区,大幅减少 IO 次数,提高读取效率。
我使用 write(int b)
和 read()
方法,分别通过字节流和字节缓冲流复制一个 524.9 mb
的 PDF 文件耗时对比如下:
使用缓冲流复制PDF文件总耗时:15428 毫秒\n使用普通字节流复制PDF文件总耗时:2555062 毫秒\n
两者耗时差别非常大,缓冲流耗费的时间是字节流的 1/165。
\n测试代码如下:
\n@Test\nvoid copy_pdf_to_another_pdf_buffer_stream() {\n // 记录开始时间\n long start = System.currentTimeMillis();\n try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream(\"深入理解计算机操作系统.pdf\"));\n BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(\"深入理解计算机操作系统-副本.pdf\"))) {\n int content;\n while ((content = bis.read()) != -1) {\n bos.write(content);\n }\n } catch (IOException e) {\n e.printStackTrace();\n }\n // 记录结束时间\n long end = System.currentTimeMillis();\n System.out.println(\"使用缓冲流复制PDF文件总耗时:\" + (end - start) + \" 毫秒\");\n}\n\n@Test\nvoid copy_pdf_to_another_pdf_stream() {\n // 记录开始时间\n long start = System.currentTimeMillis();\n try (FileInputStream fis = new FileInputStream(\"深入理解计算机操作系统.pdf\");\n FileOutputStream fos = new FileOutputStream(\"深入理解计算机操作系统-副本.pdf\")) {\n int content;\n while ((content = fis.read()) != -1) {\n fos.write(content);\n }\n } catch (IOException e) {\n e.printStackTrace();\n }\n // 记录结束时间\n long end = System.currentTimeMillis();\n System.out.println(\"使用普通流复制PDF文件总耗时:\" + (end - start) + \" 毫秒\");\n}\n
如果是调用 read(byte b[])
和 write(byte b[], int off, int len)
这两个写入一个字节数组的方法的话,只要字节数组的大小合适,两者的性能差距其实不大,基本可以忽略。
这次我们使用 read(byte b[])
和 write(byte b[], int off, int len)
方法,分别通过字节流和字节缓冲流复制一个 524.9 mb 的 PDF 文件耗时对比如下:
使用缓冲流复制PDF文件总耗时:695 毫秒\n使用普通字节流复制PDF文件总耗时:989 毫秒\n
两者耗时差别不是很大,缓冲流的性能要略微好一点点。
\n测试代码如下:
\n@Test\nvoid copy_pdf_to_another_pdf_with_byte_array_buffer_stream() {\n // 记录开始时间\n long start = System.currentTimeMillis();\n try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream(\"深入理解计算机操作系统.pdf\"));\n BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(\"深入理解计算机操作系统-副本.pdf\"))) {\n int len;\n byte[] bytes = new byte[4 * 1024];\n while ((len = bis.read(bytes)) != -1) {\n bos.write(bytes, 0, len);\n }\n } catch (IOException e) {\n e.printStackTrace();\n }\n // 记录结束时间\n long end = System.currentTimeMillis();\n System.out.println(\"使用缓冲流复制PDF文件总耗时:\" + (end - start) + \" 毫秒\");\n}\n\n@Test\nvoid copy_pdf_to_another_pdf_with_byte_array_stream() {\n // 记录开始时间\n long start = System.currentTimeMillis();\n try (FileInputStream fis = new FileInputStream(\"深入理解计算机操作系统.pdf\");\n FileOutputStream fos = new FileOutputStream(\"深入理解计算机操作系统-副本.pdf\")) {\n int len;\n byte[] bytes = new byte[4 * 1024];\n while ((len = fis.read(bytes)) != -1) {\n fos.write(bytes, 0, len);\n }\n } catch (IOException e) {\n e.printStackTrace();\n }\n // 记录结束时间\n long end = System.currentTimeMillis();\n System.out.println(\"使用普通流复制PDF文件总耗时:\" + (end - start) + \" 毫秒\");\n}\n
BufferedInputStream
从源头(通常是文件)读取数据(字节信息)到内存的过程中不会一个字节一个字节的读取,而是会先将读取到的字节存放在缓存区,并从内部缓冲区中单独读取字节。这样大幅减少了 IO 次数,提高了读取效率。
BufferedInputStream
内部维护了一个缓冲区,这个缓冲区实际就是一个字节数组,通过阅读 BufferedInputStream
源码即可得到这个结论。
public\nclass BufferedInputStream extends FilterInputStream {\n // 内部缓冲区数组\n protected volatile byte buf[];\n // 缓冲区的默认大小\n private static int DEFAULT_BUFFER_SIZE = 8192;\n // 使用默认的缓冲区大小\n public BufferedInputStream(InputStream in) {\n this(in, DEFAULT_BUFFER_SIZE);\n }\n // 自定义缓冲区大小\n public BufferedInputStream(InputStream in, int size) {\n super(in);\n if (size <= 0) {\n throw new IllegalArgumentException(\"Buffer size <= 0\");\n }\n buf = new byte[size];\n }\n}\n
缓冲区的大小默认为 8192 字节,当然了,你也可以通过 BufferedInputStream(InputStream in, int size)
这个构造方法来指定缓冲区的大小。
BufferedOutputStream
将数据(字节信息)写入到目的地(通常是文件)的过程中不会一个字节一个字节的写入,而是会先将要写入的字节存放在缓存区,并从内部缓冲区中单独写入字节。这样大幅减少了 IO 次数,提高了读取效率
try (BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(\"output.txt\"))) {\n byte[] array = \"JavaGuide\".getBytes();\n bos.write(array);\n} catch (IOException e) {\n e.printStackTrace();\n}\n
类似于 BufferedInputStream
,BufferedOutputStream
内部也维护了一个缓冲区,并且,这个缓存区的大小也是 8192 字节。
BufferedReader
(字符缓冲输入流)和 BufferedWriter
(字符缓冲输出流)类似于 BufferedInputStream
(字节缓冲输入流)和BufferedOutputStream
(字节缓冲输入流),内部都维护了一个字节数组作为缓冲区。不过,前者主要是用来操作字符信息。
下面这段代码大家经常使用吧?
\nSystem.out.print(\"Hello!\");\nSystem.out.println(\"Hello!\");\n
System.out
实际是用于获取一个 PrintStream
对象,print
方法实际调用的是 PrintStream
对象的 write
方法。
PrintStream
属于字节打印流,与之对应的是 PrintWriter
(字符打印流)。PrintStream
是 OutputStream
的子类,PrintWriter
是 Writer
的子类。
public class PrintStream extends FilterOutputStream\n implements Appendable, Closeable {\n}\npublic class PrintWriter extends Writer {\n}\n
这里要介绍的随机访问流指的是支持随意跳转到文件的任意位置进行读写的 RandomAccessFile
。
RandomAccessFile
的构造方法如下,我们可以指定 mode
(读写模式)。
// openAndDelete 参数默认为 false 表示打开文件并且这个文件不会被删除\npublic RandomAccessFile(File file, String mode)\n throws FileNotFoundException {\n this(file, mode, false);\n}\n// 私有方法\nprivate RandomAccessFile(File file, String mode, boolean openAndDelete) throws FileNotFoundException{\n // 省略大部分代码\n}\n
读写模式主要有下面四种:
\nr
: 只读模式。rw
: 读写模式rws
: 相对于 rw
,rws
同步更新对“文件的内容”或“元数据”的修改到外部存储设备。rwd
: 相对于 rw
,rwd
同步更新对“文件的内容”的修改到外部存储设备。文件内容指的是文件中实际保存的数据,元数据则是用来描述文件属性比如文件的大小信息、创建和修改时间。
\nRandomAccessFile
中有一个文件指针用来表示下一个将要被写入或者读取的字节所处的位置。我们可以通过 RandomAccessFile
的 seek(long pos)
方法来设置文件指针的偏移量(距文件开头 pos
个字节处)。如果想要获取文件指针当前的位置的话,可以使用 getFilePointer()
方法。
RandomAccessFile
代码示例:
RandomAccessFile randomAccessFile = new RandomAccessFile(new File(\"input.txt\"), \"rw\");\nSystem.out.println(\"读取之前的偏移量:\" + randomAccessFile.getFilePointer() + \",当前读取到的字符\" + (char) randomAccessFile.read() + \",读取之后的偏移量:\" + randomAccessFile.getFilePointer());\n// 指针当前偏移量为 6\nrandomAccessFile.seek(6);\nSystem.out.println(\"读取之前的偏移量:\" + randomAccessFile.getFilePointer() + \",当前读取到的字符\" + (char) randomAccessFile.read() + \",读取之后的偏移量:\" + randomAccessFile.getFilePointer());\n// 从偏移量 7 的位置开始往后写入字节数据\nrandomAccessFile.write(new byte[]{'H', 'I', 'J', 'K'});\n// 指针当前偏移量为 0,回到起始位置\nrandomAccessFile.seek(0);\nSystem.out.println(\"读取之前的偏移量:\" + randomAccessFile.getFilePointer() + \",当前读取到的字符\" + (char) randomAccessFile.read() + \",读取之后的偏移量:\" + randomAccessFile.getFilePointer());\n
input.txt
文件内容:
输出:
\n读取之前的偏移量:0,当前读取到的字符A,读取之后的偏移量:1\n读取之前的偏移量:6,当前读取到的字符G,读取之后的偏移量:7\n读取之前的偏移量:0,当前读取到的字符A,读取之后的偏移量:1\n
input.txt
文件内容变为 ABCDEFGHIJK
。
RandomAccessFile
的 write
方法在写入对象的时候如果对应的位置已经有数据的话,会将其覆盖掉。
RandomAccessFile randomAccessFile = new RandomAccessFile(new File(\"input.txt\"), \"rw\");\nrandomAccessFile.write(new byte[]{'H', 'I', 'J', 'K'});\n
假设运行上面这段程序之前 input.txt
文件内容变为 ABCD
,运行之后则变为 HIJK
。
RandomAccessFile
比较常见的一个应用就是实现大文件的 断点续传 。何谓断点续传?简单来说就是上传文件中途暂停或失败(比如遇到网络问题)之后,不需要重新上传,只需要上传那些未成功上传的文件分片即可。分片(先将文件切分成多个文件分片)上传是断点续传的基础。
RandomAccessFile
可以帮助我们合并文件分片,示例代码如下:
我在《Java 面试指北》中详细介绍了大文件的上传问题。
\n\nRandomAccessFile
的实现依赖于 FileDescriptor
(文件描述符) 和 FileChannel
(内存映射文件)。
这篇文章我们简单来看看我们从 IO 中能够学习到哪些设计模式的应用。
\n装饰器(Decorator)模式 可以在不改变原有对象的情况下拓展其功能。
\n装饰器模式通过组合替代继承来扩展原始类的功能,在一些继承关系比较复杂的场景(IO 这一场景各种类的继承关系就比较复杂)更加实用。
\n对于字节流来说, FilterInputStream
(对应输入流)和FilterOutputStream
(对应输出流)是装饰器模式的核心,分别用于增强 InputStream
和OutputStream
子类对象的功能。
我们常见的BufferedInputStream
(字节缓冲输入流)、DataInputStream
等等都是FilterInputStream
的子类,BufferedOutputStream
(字节缓冲输出流)、DataOutputStream
等等都是FilterOutputStream
的子类。
举个例子,我们可以通过 BufferedInputStream
(字节缓冲输入流)来增强 FileInputStream
的功能。
BufferedInputStream
构造函数如下:
public BufferedInputStream(InputStream in) {\n this(in, DEFAULT_BUFFER_SIZE);\n}\n\npublic BufferedInputStream(InputStream in, int size) {\n super(in);\n if (size <= 0) {\n throw new IllegalArgumentException(\"Buffer size <= 0\");\n }\n buf = new byte[size];\n}\n
可以看出,BufferedInputStream
的构造函数其中的一个参数就是 InputStream
。
BufferedInputStream
代码示例:
try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream(\"input.txt\"))) {\n int content;\n long skip = bis.skip(2);\n while ((content = bis.read()) != -1) {\n System.out.print((char) content);\n }\n} catch (IOException e) {\n e.printStackTrace();\n}\n
这个时候,你可以会想了:为啥我们直接不弄一个BufferedFileInputStream
(字符缓冲文件输入流)呢?
BufferedFileInputStream bfis = new BufferedFileInputStream(\"input.txt\");\n
如果 InputStream
的子类比较少的话,这样做是没问题的。不过, InputStream
的子类实在太多,继承关系也太复杂了。如果我们为每一个子类都定制一个对应的缓冲输入流,那岂不是太麻烦了。
如果你对 IO 流比较熟悉的话,你会发现ZipInputStream
和ZipOutputStream
还可以分别增强 BufferedInputStream
和 BufferedOutputStream
的能力。
BufferedInputStream bis = new BufferedInputStream(new FileInputStream(fileName));\nZipInputStream zis = new ZipInputStream(bis);\n\nBufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(fileName));\nZipOutputStream zipOut = new ZipOutputStream(bos);\n
ZipInputStream
和ZipOutputStream
分别继承自InflaterInputStream
和DeflaterOutputStream
。
public\nclass InflaterInputStream extends FilterInputStream {\n}\n\npublic\nclass DeflaterOutputStream extends FilterOutputStream {\n}\n\n
这也是装饰器模式很重要的一个特征,那就是可以对原始类嵌套使用多个装饰器。
\n为了实现这一效果,装饰器类需要跟原始类继承相同的抽象类或者实现相同的接口。上面介绍到的这些 IO 相关的装饰类和原始类共同的父类是 InputStream
和OutputStream
。
对于字符流来说,BufferedReader
可以用来增加 Reader
(字符输入流)子类的功能,BufferedWriter
可以用来增加 Writer
(字符输出流)子类的功能。
BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(fileName), \"UTF-8\"));\n
IO 流中的装饰器模式应用的例子实在是太多了,不需要特意记忆,完全没必要哈!搞清了装饰器模式的核心之后,你在使用的时候自然就会知道哪些地方运用到了装饰器模式。
\n适配器(Adapter Pattern)模式 主要用于接口互不兼容的类的协调工作,你可以将其联想到我们日常经常使用的电源适配器。
\n适配器模式中存在被适配的对象或者类称为 适配者(Adaptee) ,作用于适配者的对象或者类称为适配器(Adapter) 。适配器分为对象适配器和类适配器。类适配器使用继承关系来实现,对象适配器使用组合关系来实现。
\nIO 流中的字符流和字节流的接口不同,它们之间可以协调工作就是基于适配器模式来做的,更准确点来说是对象适配器。通过适配器,我们可以将字节流对象适配成一个字符流对象,这样我们可以直接通过字节流对象来读取或者写入字符数据。
\nInputStreamReader
和 OutputStreamWriter
就是两个适配器(Adapter), 同时,它们两个也是字节流和字符流之间的桥梁。InputStreamReader
使用 StreamDecoder
(流解码器)对字节进行解码,实现字节流到字符流的转换, OutputStreamWriter
使用StreamEncoder
(流编码器)对字符进行编码,实现字符流到字节流的转换。
InputStream
和 OutputStream
的子类是被适配者, InputStreamReader
和 OutputStreamWriter
是适配器。
// InputStreamReader 是适配器,FileInputStream 是被适配的类\nInputStreamReader isr = new InputStreamReader(new FileInputStream(fileName), \"UTF-8\");\n// BufferedReader 增强 InputStreamReader 的功能(装饰器模式)\nBufferedReader bufferedReader = new BufferedReader(isr);\n
java.io.InputStreamReader
部分源码:
public class InputStreamReader extends Reader {\n //用于解码的对象\n private final StreamDecoder sd;\n public InputStreamReader(InputStream in) {\n super(in);\n try {\n // 获取 StreamDecoder 对象\n sd = StreamDecoder.forInputStreamReader(in, this, (String)null);\n } catch (UnsupportedEncodingException e) {\n throw new Error(e);\n }\n }\n // 使用 StreamDecoder 对象做具体的读取工作\n public int read() throws IOException {\n return sd.read();\n }\n}\n
java.io.OutputStreamWriter
部分源码:
public class OutputStreamWriter extends Writer {\n // 用于编码的对象\n private final StreamEncoder se;\n public OutputStreamWriter(OutputStream out) {\n super(out);\n try {\n // 获取 StreamEncoder 对象\n se = StreamEncoder.forOutputStreamWriter(out, this, (String)null);\n } catch (UnsupportedEncodingException e) {\n throw new Error(e);\n }\n }\n // 使用 StreamEncoder 对象做具体的写入工作\n public void write(int c) throws IOException {\n se.write(c);\n }\n}\n
适配器模式和装饰器模式有什么区别呢?
\n装饰器模式 更侧重于动态地增强原始类的功能,装饰器类需要跟原始类继承相同的抽象类或者实现相同的接口。并且,装饰器模式支持对原始类嵌套使用多个装饰器。
\n适配器模式 更侧重于让接口不兼容而不能交互的类可以一起工作,当我们调用适配器对应的方法时,适配器内部会调用适配者类或者和适配类相关的类的方法,这个过程透明的。就比如说 StreamDecoder
(流解码器)和StreamEncoder
(流编码器)就是分别基于 InputStream
和 OutputStream
来获取 FileChannel
对象并调用对应的 read
方法和 write
方法进行字节数据的读取和写入。
StreamDecoder(InputStream in, Object lock, CharsetDecoder dec) {\n // 省略大部分代码\n // 根据 InputStream 对象获取 FileChannel 对象\n ch = getChannel((FileInputStream)in);\n}\n
适配器和适配者两者不需要继承相同的抽象类或者实现相同的接口。
\n另外,FutureTask
类使用了适配器模式,Executors
的内部类 RunnableAdapter
实现属于适配器,用于将 Runnable
适配成 Callable
。
FutureTask
参数包含 Runnable
的一个构造方法:
public FutureTask(Runnable runnable, V result) {\n // 调用 Executors 类的 callable 方法\n this.callable = Executors.callable(runnable, result);\n this.state = NEW;\n}\n
Executors
中对应的方法和适配器:
// 实际调用的是 Executors 的内部类 RunnableAdapter 的构造方法\npublic static <T> Callable<T> callable(Runnable task, T result) {\n if (task == null)\n throw new NullPointerException();\n return new RunnableAdapter<T>(task, result);\n}\n// 适配器\nstatic final class RunnableAdapter<T> implements Callable<T> {\n final Runnable task;\n final T result;\n RunnableAdapter(Runnable task, T result) {\n this.task = task;\n this.result = result;\n }\n public T call() {\n task.run();\n return result;\n }\n}\n
工厂模式用于创建对象,NIO 中大量用到了工厂模式,比如 Files
类的 newInputStream
方法用于创建 InputStream
对象(静态工厂)、 Paths
类的 get
方法创建 Path
对象(静态工厂)、ZipFileSystem
类(sun.nio
包下的类,属于 java.nio
相关的一些内部实现)的 getPath
的方法创建 Path
对象(简单工厂)。
InputStream is = Files.newInputStream(Paths.get(generatorLogoPath))\n
NIO 中的文件目录监听服务使用到了观察者模式。
\nNIO 中的文件目录监听服务基于 WatchService
接口和 Watchable
接口。WatchService
属于观察者,Watchable
属于被观察者。
Watchable
接口定义了一个用于将对象注册到 WatchService
(监控服务) 并绑定监听事件的方法 register
。
public interface Path\n extends Comparable<Path>, Iterable<Path>, Watchable{\n}\n\npublic interface Watchable {\n WatchKey register(WatchService watcher,\n WatchEvent.Kind<?>[] events,\n WatchEvent.Modifier... modifiers)\n throws IOException;\n}\n
WatchService
用于监听文件目录的变化,同一个 WatchService
对象能够监听多个文件目录。
// 创建 WatchService 对象\nWatchService watchService = FileSystems.getDefault().newWatchService();\n\n// 初始化一个被监控文件夹的 Path 类:\nPath path = Paths.get(\"workingDirectory\");\n// 将这个 path 对象注册到 WatchService(监控服务) 中去\nWatchKey watchKey = path.register(\nwatchService, StandardWatchEventKinds...);\n
Path
类 register
方法的第二个参数 events
(需要监听的事件)为可变长参数,也就是说我们可以同时监听多种事件。
WatchKey register(WatchService watcher,\n WatchEvent.Kind<?>... events)\n throws IOException;\n
常用的监听事件有 3 种:
\nStandardWatchEventKinds.ENTRY_CREATE
:文件创建。StandardWatchEventKinds.ENTRY_DELETE
: 文件删除。StandardWatchEventKinds.ENTRY_MODIFY
: 文件修改。register
方法返回 WatchKey
对象,通过WatchKey
对象可以获取事件的具体信息比如文件目录下是创建、删除还是修改了文件、创建、删除或者修改的文件的具体名称是什么。
WatchKey key;\nwhile ((key = watchService.take()) != null) {\n for (WatchEvent<?> event : key.pollEvents()) {\n // 可以调用 WatchEvent 对象的方法做一些事情比如输出事件的具体上下文信息\n }\n key.reset();\n}\n
WatchService
内部是通过一个 daemon thread(守护线程)采用定期轮询的方式来检测文件的变化,简化后的源码如下所示。
class PollingWatchService\n extends AbstractWatchService\n{\n // 定义一个 daemon thread(守护线程)轮询检测文件变化\n private final ScheduledExecutorService scheduledExecutor;\n\n PollingWatchService() {\n scheduledExecutor = Executors\n .newSingleThreadScheduledExecutor(new ThreadFactory() {\n @Override\n public Thread newThread(Runnable r) {\n Thread t = new Thread(r);\n t.setDaemon(true);\n return t;\n }});\n }\n\n void enable(Set<? extends WatchEvent.Kind<?>> events, long period) {\n synchronized (this) {\n // 更新监听事件\n this.events = events;\n\n // 开启定期轮询\n Runnable thunk = new Runnable() { public void run() { poll(); }};\n this.poller = scheduledExecutor\n .scheduleAtFixedRate(thunk, period, period, TimeUnit.SECONDS);\n }\n }\n}\n
IO 模型这块确实挺难理解的,需要太多计算机底层知识。写这篇文章用了挺久,就非常希望能把我所知道的讲出来吧!希望朋友们能有收获!为了写这篇文章,还翻看了一下《UNIX 网络编程》这本书,太难了,我滴乖乖!心痛~
\n个人能力有限。如果文章有任何需要补充/完善/修改的地方,欢迎在评论区指出,共同进步!
\nI/O 一直是很多小伙伴难以理解的一个知识点,这篇文章我会将我所理解的 I/O 讲给你听,希望可以对你有所帮助。
\nI/O(Input/Outpu) 即输入/输出 。
\n我们先从计算机结构的角度来解读一下 I/O。
\n根据冯.诺依曼结构,计算机结构分为 5 大部分:运算器、控制器、存储器、输入设备、输出设备。
\n\n输入设备(比如键盘)和输出设备(比如显示器)都属于外部设备。网卡、硬盘这种既可以属于输入设备,也可以属于输出设备。
\n输入设备向计算机输入数据,输出设备接收计算机输出的数据。
\n从计算机结构的视角来看的话, I/O 描述了计算机系统与外部设备之间通信的过程。
\n我们再先从应用程序的角度来解读一下 I/O。
\n根据大学里学到的操作系统相关的知识:为了保证操作系统的稳定性和安全性,一个进程的地址空间划分为 用户空间(User space) 和 内核空间(Kernel space ) 。
\n像我们平常运行的应用程序都是运行在用户空间,只有内核空间才能进行系统态级别的资源有关的操作,比如文件管理、进程通信、内存管理等等。也就是说,我们想要进行 IO 操作,一定是要依赖内核空间的能力。
\n并且,用户空间的程序不能直接访问内核空间。
\n当想要执行 IO 操作时,由于没有执行这些操作的权限,只能发起系统调用请求操作系统帮忙完成。
\n因此,用户进程想要执行 IO 操作的话,必须通过 系统调用 来间接访问内核空间
\n我们在平常开发过程中接触最多的就是 磁盘 IO(读写文件) 和 网络 IO(网络请求和响应)。
\n从应用程序的视角来看的话,我们的应用程序对操作系统的内核发起 IO 调用(系统调用),操作系统负责的内核执行具体的 IO 操作。也就是说,我们的应用程序实际上只是发起了 IO 操作的调用而已,具体 IO 的执行是由操作系统的内核来完成的。
\n当应用程序发起 I/O 调用后,会经历两个步骤:
\nUNIX 系统下, IO 模型一共有 5 种:同步阻塞 I/O、同步非阻塞 I/O、I/O 多路复用、信号驱动 I/O 和异步 I/O。
\n这也是我们经常提到的 5 种 IO 模型。
\nBIO 属于同步阻塞 IO 模型 。
\n同步阻塞 IO 模型中,应用程序发起 read 调用后,会一直阻塞,直到内核把数据拷贝到用户空间。
\n\n在客户端连接数量不高的情况下,是没问题的。但是,当面对十万甚至百万级连接的时候,传统的 BIO 模型是无能为力的。因此,我们需要一种更高效的 I/O 处理模型来应对更高的并发量。
\nJava 中的 NIO 于 Java 1.4 中引入,对应 java.nio
包,提供了 Channel
, Selector
,Buffer
等抽象。NIO 中的 N 可以理解为 Non-blocking,不单纯是 New。它是支持面向缓冲的,基于通道的 I/O 操作方法。 对于高负载、高并发的(网络)应用,应使用 NIO 。
Java 中的 NIO 可以看作是 I/O 多路复用模型。也有很多人认为,Java 中的 NIO 属于同步非阻塞 IO 模型。
\n跟着我的思路往下看看,相信你会得到答案!
\n我们先来看看 同步非阻塞 IO 模型。
\n\n同步非阻塞 IO 模型中,应用程序会一直发起 read 调用,等待数据从内核空间拷贝到用户空间的这段时间里,线程依然是阻塞的,直到在内核把数据拷贝到用户空间。
\n相比于同步阻塞 IO 模型,同步非阻塞 IO 模型确实有了很大改进。通过轮询操作,避免了一直阻塞。
\n但是,这种 IO 模型同样存在问题:应用程序不断进行 I/O 系统调用轮询数据是否已经准备好的过程是十分消耗 CPU 资源的。
\n这个时候,I/O 多路复用模型 就上场了。
\n\nIO 多路复用模型中,线程首先发起 select 调用,询问内核数据是否准备就绪,等内核把数据准备好了,用户线程再发起 read 调用。read 调用的过程(数据从内核空间 -> 用户空间)还是阻塞的。
\n\n\n目前支持 IO 多路复用的系统调用,有 select,epoll 等等。select 系统调用,目前几乎在所有的操作系统上都有支持。
\n\n
\n- select 调用:内核提供的系统调用,它支持一次查询多个系统调用的可用状态。几乎所有的操作系统都支持。
\n- epoll 调用:linux 2.6 内核,属于 select 调用的增强版本,优化了 IO 的执行效率。
\n
IO 多路复用模型,通过减少无效的系统调用,减少了对 CPU 资源的消耗。
\nJava 中的 NIO ,有一个非常重要的选择器 ( Selector ) 的概念,也可以被称为 多路复用器。通过它,只需要一个线程便可以管理多个客户端连接。当客户端数据到了之后,才会为其服务。
\n\nAIO 也就是 NIO 2。Java 7 中引入了 NIO 的改进版 NIO 2,它是异步 IO 模型。
\n异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。
\n\n目前来说 AIO 的应用还不是很广泛。Netty 之前也尝试使用过 AIO,不过又放弃了。这是因为,Netty 使用了 AIO 之后,在 Linux 系统上的性能并没有多少提升。
\n最后,来一张图,简单总结一下 Java 中的 BIO、NIO、AIO。
\n\n