坑系列 —— 缓存 + 哈希 = 高并发?

今天继续坑系列,高可用已经讲过了,当前互联网时代,怎么少的了高并发呢?高并发高可用一样, 已经变成各个系统的标配了,如果你的系统QPS没有个大几千上万,都不好意思跟人打招呼,虽然可能每天的调用量不超过100。

高并发这个词,我个人感觉是从电商领域开始往外流传的,特别是电商领域双11那种藐视全球的流量,再把技术架构出来分享一把,现在搞得全互联网都在说高并发,而且你注意回忆一下所有你看到的高并发系统,往往都逃不开一个核心概念,那就是缓存+哈希,一切都是以这个概念和基础的,仿佛这就是高并发的核心技术了。`

我们看到的高并发技术

围绕这个核心技术,通常我们看到的各种高并发的架构系统,在博客,论坛,现场分享出来的高并发系统,都跑不出以下几个方面的东西。

资源静态化

就是那种单个页面流量巨大无比,每秒的QPS几十万上百万的系统,确实并发高的系统,核心解决方案就是静态化,靠机器和带宽去抗,假如没有CDN的话,所有流量都落到同一个IP下面的话,基本上也就是用Nginx的文件静态化了,单机的承受能力主要取决于带宽和单机的性能,要再多的话,那就LVS(或者F5)+集群了,这种的典型场景就是搞活动时候的首页,活动页面了,还有就是引流搜索引擎的着陆页了,一般都是现成的图片和文字静态化,当然,这种还有很多前端的技巧和技术了,这一点我不是很了解,就不得瑟了,就中后台来说,大部分情况下直接Nginx搞定了,核心还是使用了缓存技术

读写分离和分库分表

读写分离是大家看到的第二个高并发的架构了,也很常规,因为一般情况下读比写要多得多,所以数据库的主库写,从库们提供读操作,一下就把数据库的并发性能提高了。

如果还不够,那么分库分表把,把数据分到各个数据库的各个机器上,进一步的减少单台机器的压力,从而达到高并发的目的。

如果是分库分表,有时候使用的就是哈希技术了,以某个字段哈希一下然后来分库分表,读写分离的读操作,基本也是通过哈希技术把读落到不同的机器上去减轻单机压力。

万能的缓存

说到高并发,不得不说缓存了,现在各种缓存的工具也很多也很成熟,memcache,redis之类的KV数据库作为缓存已经非常成熟了,而且基本上都可以集群化部署,操作起来也很简单,简直变成了一个高并发的代言词了,核心就是缓存技术了,而memcacheredis只是用来实现这个缓存技术的工具而已。

无敌的哈希

但凡大数据处理,高并发系统,必言哈希,随机插入,时间复杂度O(1),随便查询,时间复杂度O(1),除了耗费点空间以外,几乎没什么缺点了,在现在这个内存廉价的时代,哈希表变成了一个高并发系统的标配。

正面的例子

我们来看个例子,看看一些个大家眼中标准的高并发系统的设计,这些设计大家应该都看过,无非就是上面的几个要点,最重要的就是缓存+哈希,这两个东西的组合好像无所不能。

活动秒杀页面

活动秒杀页面,这是个标准的高并发吧,到了搞活动的那个时刻,单页面的访问量是天量数据了,但这种系统有个特点逻辑简单,只要带宽和性能够,就一定能提供稳定的服务 服务能迅速的返回数据即可,没有什么计算逻辑,这种高并发系统的设计基本上就是在怎么压榨机器的IO性能了,如果有CDN绝对使用CDN,能在本机读取的绝不走网络获取,能读取到内存中绝不放在硬盘上,把系统的磁盘IO和网络IO都尽可能的压榨,使用缓存+哈希技术,只要设计合理,99%的情况能搞定。

活动页面的冲击感实在太强,想象一下几千万人同时访问网站还没有挂,让很多人觉得高并发应该就是这样子,这估计也是高并发这次经常在电商技术中出现的原因吧,因为搞个活动就可以搞出一个高并发事件。

这样的场景再扩展一点,就是凡是能提前提供数据的并发访问,就可以用缓存+哈希来搞定并发。

12306

接下来,我们再看看这个星球并发量最疯狂的网站,瞬间的访问量碾压其他网站的12306,这种场景下的高并发也有个特点,那就是虽然量大,但其实无法给每个用户提供服务

类似的其实还有商品的抢购系统,商品和车票一共就1000张,你100万的人抢,你系统做得再好,也无法给100万人提供服务,之前12306刚刚上线的时候很多人喷,说如果让某某公司来做肯定能做好,但大家很多只看到了表面,让某很厉害的公司来做,最多也只能做到大家访问的时候不会挂掉,你买不到车票还是买不到,而且现在的12306体验也已经做得很好了,也不卡了,但是还是很多人骂,为什么?还不是因为买不到票。

对于这样的系统,设计关注的就不仅仅是提高性能了,因为性能瓶颈已经摆在那了,就1000张票,做得更多的是分流和限流了,除了缓存+哈希来保证用户体验以外,出现了奇葩验证码,各个站点分时间点放票。然后通过排队系统来以一种异步的方式提供最终的服务。

我们给这样的场景再扩展一下,凡是不能提前提供数据的,可以通过缓存+哈希来提高用户体验,然后通过异步方式来提供服务。

高并发系统如何设计

如果把上面两个场景的情况合并一下,仿佛缓存+哈希变成万能的了,很多人眼中的高并发就是上面的场景的组合,认为缓存+哈希就可以解决高并发的问题,其他的场景中,加上缓存提高读写速度,在加上哈希提供分流技术,再通过一个异步提供最终服务,高并发就这么搞定了,但实际上是不是这样呢?显然没那么简单,那如何来设计一个高并发的系统呢?

合理的数据结构

举个例子来说吧,搜索提示功能大家都知道吧,就是下面这个图的东西。

如果是google,baidu这种大型搜索系统,或者京东淘宝这种电商系统,搜索提示的调用量是搜索服务本身调用量的几倍,因为你每输入一个键盘,就要调用一次搜索提示服务,这算得上是个标准的高并发系统吧?那么它是怎么实现的呢?

可能很多人脑子里立刻出现了缓存+哈希的系统,把搜索的搜索提示词存在redis集群中,每次来了请求直接redis集群中查找key,然后返回相应的value值就行了,完美解决,虽然耗费点内存,但是空间换时间嘛,也能接受,这么做行不行?恩,我觉得是可以的,但有人这么做吗?没有。

了解的人应该知道,没有人会这么来实现,这种搜索提示的功能一般用trie树来做,耗费的内存不多,查找速度为O(k),其中k为字符串的长度,虽然看上去没有哈希表的O(1)好,但是少了网络开销,节约了很多内存,并且实际查找时间还要不比缓存+哈希慢多少,一种合适当前场景的核心数据结构才是高并发系统的关键,缓存+哈希如果也看成一种数据结构,但这种数据结构并不适用于所有的高并发场景,所以

高并发系统的设计,关键在合理的数据结构的设计,而不在架构的套用

不断的代码性能优化

有了上面的数据结构,并且设计出了系统了,拿到线上一跑,效果还行,但感觉没达到极限,这时候可千万不能就直接上外部工具(比如缓存)提升性能,需要做的是不断的代码性能的优化,简单的说,就是不断的review你的代码,不断的找出可以优化的性能点,然后进行优化,因为之前设计的时候就已经通过理论大概能算出来这个系统的并发量了,比如上面那个搜索提示,如果我们假定平均每个搜索词6个字符,检索一次大约需要查询6次,需要2-3毫秒,这样的话,如果8核的机器,多线程编程方式,一秒钟最多能接受3200次请求(1000ms/2.5ms*8),如果没达到这个量级,那么肯定是代码哪里有问题。

这个阶段可能需要借助一些个工具了,JAVA有JAVA的性能优化工具,大家都有自己觉得好使的,我本身JAVA用得很少,也没啥可推荐的,如果是Golang的话,自带的go tool pprof就能很好的进行性能优化。

或者最简单的,就是把各个模块的时间打印出来,压测一遍,看看哪个模块耗时,然后再去仔细review那个模块的代码,进行算法和数据结构的优化,我个人比较推崇这个办法,虽然比较笨,但是比较实在,性能差就是差,比较直观能看出来,也能看出需要优化的点,而且比较贴近代码,少了外部工具的干扰,可能也比较装逼吧。

这个过程是一个长期的过程,也是《重构:改善代码的既有设计》中提到的,一个优秀的系统需要不断的进行代码级别的优化和重构,所以

高并发系统的实现,就是不断的优化你代码的性能,不断逼近你设计时的理论值

再考虑外部通用方法

以上两个都完成了,并发量也基本达到理论值了,但是还有提升的需求,这时候再来考虑外部的通用方法,比如加一个LRU缓存,把热词的查询时间变成O(1),进一步提高性能。

说起LRU,多说一句,这是个标准的缓存技术了,实现起来代码也不复杂,就是个哈希表+链表的数据结构,一个合格的开发人员,即便没有听说过,给定一个场景,应该也能自己设计出来,我见过很多简历都说自己有大型高并发系统的开发经验,能熟练运用各种缓存技术,也对缓存技术有深入的了解,但是一面试的时候我让他写个LRU,首先有50%的人没听说过,OK,没听过没关系,我描述一下,然后给一个场景,硬盘上有N条数据,并且有一个程序包,提供GET和SET方法,可以操作磁盘读写数据,但是速度太慢,请设计一个内存中的数据结构,也提供GET和SET方法,保存最近访问的前100条数据,这个数据结构就是一个LRU了,让面试者实现出来,如果觉得写代码麻烦,可以把数据结构设计出来描述一下就行了,就这样,还很多人不会,这怎么能说是对缓存技术有深入了解呢?就这样,怎么能说有过大型高并发系统的经验呢?这只是开源工具的使用经验罢了。

在没把系统的性能压榨完全之前,不要使用外部的通用方法,因为使用了以后就没有太多进一步优化空间了。

最后靠运维技术了

上面几种都已经弄完了,还需要提升性能,这时候再考虑运维的技术了,比如常规的加负载均衡,部署成集群之类的,通过运维和部署的方法提高服务的并发性。

高并发系统只是相对的,没有什么无上限的高并发,流量的洪流来了,再高的高并发一样挂,新浪微博的高并发应该做得很好吧?但是林心如发条微博说她和霍建华谈恋爱了,一样把微博搞挂了(非官方消息啊,我猜测的,呵呵,那天下午新浪微博正好挂了),呵呵,你说要是TFBOY明天过生日,微博是不是要连夜加几个redis集群啊?如果有微博的朋友,留个言溜溜呗:)

总结

罗里吧嗦说了这么多,其实我就想表达一个意思,不管是前面的高可用,还是今天的高并发

代码才是关键,架构都是锦上添花的东西,既然是锦上添花的,必然坑多,没有什么捷径。

代码的健壮性决定了高可用,这些印度人就能做到,而高性能,高并发需要的不仅仅是代码的健壮性,还有数据结构的设计和代码的调优能力了。

架构模式是大家总结出来的,和你的系统可能关系不是很大,学习太多的架构,脑袋会乱,还不如实打实的看几本书,然后对着架构多推敲练习,很多人对数据结构嗤之以鼻,觉得对于现有的开发来说,数据结构没那么重要了,但对于后端开发来说,数据结构是很重要的技能,虽然面试的时候不会让你去翻转一棵二叉树,但二叉树是什么,什么场景下用还是应该知道的吧?

找准合适的数据结构,不断的优化代码,这样来提升你的系统性能,这样的系统才是你可控的,才有不断优化的空间,更好的高并发,如果一开始就上外部的缓存技术,很可能如果性能达不到要求,就没有优化空间了,因为要修改外部的系统还是很困难的。

这几篇我觉得我都在瞎扯淡,虽然比较虚,但也是我工作这么些年淌坑无数以后的总结吧,后面的文章会写一些实际的了,我的搜索引擎部分还没写完呢。尽请期待。呵呵。。


如果你觉得不错,欢迎转发给更多人看到,也欢迎关注我的公众号,主要聊聊搜索,推荐,广告技术,还有瞎扯。。文章会在这里首先发出来:)扫描或者搜索微信号XJJ267或者搜索西加加语言就行

<think>我们正在分析Java中HashMap的hash方法为什么被设计为static final。根据引用[1]和[2],hash方法的源码如下: ```java static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); } ``` 这个方法的作用是计算键(key)的哈希值。接下来,我们将从几个方面分析为什么这个方法被设计为`static final`。 ### 1. **`static`修饰的原因** - **不依赖实例状态**:`hash`方法不依赖于`HashMap`实例的任何状态(即实例变量)。它仅根据输入的`key`计算哈希值。因此,将其声明为`static`是合理的,这样可以直接通过类名调用,而不需要创建`HashMap`的实例。 - **性能优化**:静态方法在调用时不需要通过对象实例,因此调用速度稍快(虽然这种优化在大多数情况下微乎其微,但在高频使用的核心类如`HashMap`中是有意义的)。 - **内存节省**:由于不依赖于实例,所以不会占用每个实例的内存空间。 ### 2. **`final`修饰的原因** - **禁止子类重写**:`final`修饰符确保该方法不能在子类中被重写。这是因为`HashMap`的哈希算法是固定的,如果子类重写了该方法,可能会导致`HashMap`内部逻辑混乱(例如,在计算桶位置时不一致)。 - **保证一致性**:`HashMap`内部依赖于这个哈希算法的一致性。例如,在计算桶位置时,使用`(n - 1) & hash`(其中`n`是桶数组的长度)[^2]。如果这个方法被重写,可能会导致相同的键在不同子类中映射到不同的桶,破坏`HashMap`的行为。 - **内联优化**:`final`方法在编译时和运行时更容易被内联(inlining),即编译器或JVM可能会将方法体直接嵌入到调用处,从而减少方法调用的销。这对于像`hash`这样被频繁调用的方法(在`get`、`put`等操作中都会用到)尤为重要。 ### 3. **设计意图** - **高效性**:哈希算法需要尽可能快地计算,同时减少哈希冲突。该算法通过将`key`的原始哈希码(`hashCode()`返回)的高16位低16位进行异或运算,使得高位的信息也能参到后续的桶位置计算中,从而减少冲突[^1][^2]。 - **空键处理**:当`key`为`null`时,返回0,因此`null`键总是存储在桶数组的第一个位置(即索引0处)[^1]。 - **稳定性**:由于算法是固定的,且被声明为`final`,确保了在整个`HashMap`的实现中哈希计算的一致性。 ### 4. **`HashMap`其他设计的协同** - **桶定位**:计算出的哈希值会`(n-1)`(其中`n`是桶数组的长度)进行按位运算来确定桶的位置。这个操作要求哈希值具有良好的分布性,而`hash`方法的设计正是为了增强这种分布性[^2]。 - **树化反树化**:在`HashMap`中,当链表过长时会转化为红黑树(树化),而当节点变少时又会退化为链表(反树化)。哈希值的稳定性对于这些操作的效率至关重要。 ### 5. **为什么不是实例方法?** - 如果`hash`是实例方法,那么每次调用都需要通过`HashMap`的实例。但实际上,该方法只依赖于参数`key`,实例状态无关。因此,设计为静态方法更合理。 - 此外,在`HashMap`的某些内部方法(如`putVal`、`getNode`)中,需要频繁调用`hash`方法。静态方法调用避免了额外的实例引用访问,理论上更高效。 ### 6. **使用`final`的注意事项** - 虽然`final`方法不能被重写,但`HashMap`本身不是`final`类,所以子类仍然可以继承`HashMap`。然而,子类无法改变哈希计算的方式,这保证了`HashMap`核心行为的一致性。 - 在`HashMap`的扩展类中(如`LinkedHashMap`),它们也依赖于这个哈希方法,因此保持该方法不可变确保了整个`Map`实现家族的稳定性。 ### 总结 `HashMap`的`hash`方法设计为`static final`的原因主要有: 1. **静态性(`static`)**:因为该方法不依赖于实例状态,且可直接通过类名调用,节省资源并提高效率。 2. **不可变性(`final`)**:确保哈希算法在子类中不能被修改,保证`HashMap`内部逻辑的一致性,同时为JVM优化(如内联)提供条件。 这种设计体现了对性能、稳定性和扩展性的综合考虑,是`HashMap`高效实现的关键因素之一。 --- ### 相关问题 1. `HashMap`中如何处理`null`键? 2. 为什么`HashMap`的桶数组长度总是2的幂? 3. `HashMap`的`hash`方法`key`的`hashCode()`有何区别? 4. `HashMap`在Java 8中引入了红黑树,这对哈希计算有何影响? 5. 为什么`HashMap`的`hash`方法中要使用异或运算(`^`)和位移(`>>>`)操作? [^1]: [来看一下 hash 方法的源码(JDK 8 中的 HashMap)](引用1) [^2]: [Java HashMap——Hash算法](引用2)</think>### Java HashMap 的 `hash()` 方法为何设计为 `static final`? #### 一、`static` 修饰的原因 1. **无状态性** `hash()` 方法仅依赖输入参数 `key`, `HashMap` 实例状态无关(如容量、负载因子等)。静态方法无需实例化即可调用,符合无状态工具方法的特性[^1][^2]: ```java // 无需访问实例变量 static final int hash(Object key) { return (key == null) ? 0 : key.hashCode() ^ (key.hashCode() >>> 16); } ``` 2. **性能优化** 静态方法调用**跳过虚方法表查找**,直接绑定到方法体。在 `HashMap` 高频操作(`get`/`put`)中可减少微秒级销: ```java // 调用示例(伪汇编) invokestatic HashMap.hash // 静态调用指令 vs invokevirtual HashMap.hash // 虚方法调用(需查表) ``` 3. **内存节省** 静态方法**不占用每个实例的方法表空间**。对于可能创建大量 `HashMap` 实例的场景(如缓存系统),可减少内存占用[^4]。 #### 二、`final` 修饰的原因 1. **禁止篡改核心算法** `final` 确保子类无法重写哈希算法,防止破坏 `HashMap` 的桶定位逻辑: ```java (n - 1) & hash(key) // 桶位置计算公式[^2] ``` 若子类重写 `hash()` 导致分布变化,可能引发: - 哈希冲突激增 - 红黑树退化链表 - 数据丢失风险 2. **保障内部一致性** `HashMap` 的树化(`treeifyBin`)、扩容等操作依赖稳定的哈希值。`final` 确保全生命周期中哈希计算逻辑一致[^3]。 3. **JVM 内联优化** `final` 方法会被编译器标记为**可内联候选**,JVM 可将方法体直接嵌入调用处: ```java // 内联优化前 int hash = HashMap.hash(key); int index = (n-1) & hash; // 内联优化后(伪代码) int h = key.hashCode(); int hash = h ^ (h >>> 16); int index = (n-1) & hash; ``` 消除方法调用销(栈帧分配/参数传递),对高频操作性能提升显著[^2]。 #### 三、设计必要性分析 | 场景 | 非 static final 的风险 | static final 的优势 | |---------------------|-------------------------------|-----------------------------| | **子类扩展** | 可能重写 hash() 破坏桶分布 | 算法不可变,行为可预测 | | **多线程访问** | 并发修改导致哈希计算不一致 | 无状态+不可变,线程安全 | | **JIT 编译优化** | 无法内联,存在方法调用销 | 可内联优化,提升吞吐量 | | **序列化/反序列化** | 自定义 hash() 可能破坏数据分布 | 始终使用标准算法保证一致性 | #### 四、其他设计的协同 1. ** `final` 字段配合** `HashMap` 中的常量(如 `DEFAULT_LOAD_FACTOR`)也使用 `final`,共同确保核心参数不可变[^4]: ```java static final float DEFAULT_LOAD_FACTOR = 0.75f; ``` 2. **桶定位公式联动** 哈希算法专为 `(n-1) & hash` 优化(`n` 是 2 的幂),通过高位异或解决低位相似键的冲突问题: ```java // 原始哈希: 0000 1111 0000 1111 // 高位异或: 0000 1111 XOR 0000 0000 = 0000 1111 // 桶定位: (16-1=15) & 0000 1111 = 15 (均匀分布) ``` #### 五、典型应用场景 1. **键为 `null` 的处理** 显式处理 `null` 键为哈希值 0,确保 `null` 键始终存储在桶 0[^1]: ```java return (key == null) ? 0 : ... // 明确语义 ``` 2. **防御劣质 `hashCode()`** 通过扰动函数抑制劣质哈希码的冲突: ```java // 示例:多个键的 hashCode() 低位相同 key1.hashCode() = 0x0000ffff key2.hashCode() = 0xffffffff // 扰动后: hash(key1) = 0xffff0000 ^ 0x0000ffff = 0xffffffff hash(key2) = 0xffffffff ^ 0x0000ffff = 0xffff0000 ``` --- ### 相关问题 1. 为什么 `HashMap` 桶数组长度总是 2 的幂? 2. `HashMap` 如何处理哈希冲突? 3. `final` 方法在 JVM 内联优化中的具体机制是什么? 4. 为什么 `String` 类常被用作 `HashMap` 的键? 5. Java 17 中 `HashMap` 的哈希算法是否有变化? [^1]: [JDK 8 HashMap.hash() 源码](引用1) [^2]: [HashMap 的哈希算法设计解析](引用2) [^3]: [HashMap.get() 方法依赖的哈希稳定性](引用3) [^4]: [final static 修饰集合的影响](引用4)
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值