首页 文章 精选 留言 我的

精选列表

搜索[阿里云],共10000篇文章
优秀的个人博客,低调大师

阿里三面:让你怀疑人生的Java锁机制夺命连环21问

前言 锁机制无处不在,锁机制是实现线程同步的基础,锁机制并不是Java锁独有的,其他各种计算机语言中也有着锁机制相关的实现,数据库中也有锁的相关内容,这篇文章总结的Java锁机制笔记也为大家打包好了,需要的自取即可,希望可以帮助大家从Java入手,深入学习、理解Java中的锁机制,提升Java并发编程能力。 1、乐观锁 乐观锁是一种乐观思想,假定当前环境是读多写少,遇到并发写的概率比较低,读数据时认为别的线程不会正在进行修改(所以没有上锁)。写数据时,判断当前 与期望值是否相同,如果相同则进行更新(更新期间加锁,保证是原子性的)。 Java 中的乐观锁: CAS,比较并替换,比较当前值(主内存中的值),与预期值(当前线程中的值,主内存中值的一份拷贝)是否一样,一样则更新,否则继续进行 CAS 操作。 如上图所示,可以同时进行读操作,读的时候其他线程不能进行写操作。 2、悲观锁 悲观锁是一种悲观思想,即认为写多读少,遇到并发写的可能性高,每次去拿数据的时候都认为其他线程会修改,所以每次读写数据都会认为其他线程会修改,所以每次读写数据时都会上锁。其他线程想要读写这个数据时,会被这个线程 block,直到这个线程释放锁然后其他线程获取到锁。 Java 中的悲观锁: synchronized修饰的方法和方法块、ReentrantLock。 如上图所示,只能有一个线程进行读操作或者写操作,其他线程的读写操作均不能进行。 3、自旋锁 自旋锁是一种技术: 为了让线程等待,我们只须让线程执行一个忙循环(自旋)。 现在绝大多数的个人电脑和服务器都是多路(核)处理器系统,如果物理机器有一个以上的处理器或者处理器核心。 能让两个或以上的线程同时并行执行,就可以让后面请求锁的那个线程“稍等一会”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。 自旋锁的优点: 避免了线程切换的开销。挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给 Java 虚拟机的并发性能带来了很大的压力。 自旋锁的缺点: 占用处理器的时间,如果占用的时间很长,会白白消耗处理器资源,而不会做任何有价值的工作,带来性能的浪费。因此自旋等待的时间必须有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程。 自旋次数默认值:10 次,可以使用参数-XX:PreBlockSpin 来自行更改。 自适应自旋: 自适应意味着自旋的时间不再是固定的,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。有了自适应自旋,随着程序运行时间的增长及性能监控信息的不断完善,虚拟机对程序锁的状态预测就会越来越精准。 Java 中的自旋锁: CAS 操作中的比较操作失败后的自旋等待。 4、可重入锁(递归锁) 可重入锁是一种技术: 任意线程在获取到锁之后能够再次获取该锁而不会被锁所阻塞。 可重入锁的原理: 通过组合自定义同步器来实现锁的获取与释放。 再次获取锁:识别获取锁的线程是否为当前占据锁的线程,如果是,则再次成功获取。获取锁后,进行计数自增, 释放锁:释放锁时,进行计数自减。 Java 中的可重入锁: ReentrantLock、synchronized 修饰的方法或代码段。 可重入锁的作用: 避免死锁。 面试题 1: 可重入锁如果加了两把,但是只释放了一把会出现什么问题? 答:程序卡死,线程不能出来,也就是说我们申请了几把锁,就需要释放几把锁。 面试题 2: 如果只加了一把锁,释放两次会出现什么问题? 答:会报错, java.lang.IllegalMonitorStateException。 5、读写锁 读写锁是一种技术: 通过ReentrantReadWriteLock类来实现。为了提高性能, Java 提供了读写锁,在读的地方使用读锁,在写的地方使用写锁,灵活控制,如果没有写锁的情况下,读是无阻塞的,在一定程度上提高了程序的执行效率。读写锁分为读锁和写锁,多个读锁不互斥,读锁与写锁互斥,这是由 jvm 自己控制的。 读锁: 允许多个线程获取读锁,同时访问同一个资源。 写锁: 只允许一个线程获取写锁,不允许同时访问同一个资源。 如何使用: /** * 创建一个读写锁 * 它是一个读写融为一体的锁,在使用的时候,需要转换 */ private ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock(); 获取读锁和释放读锁 // 获取读锁 rwLock.readLock().lock(); // 释放读锁 rwLock.readLock().unlock(); 获取写锁和释放写锁 // 创建一个写锁 rwLock.writeLock().lock(); // 写锁 释放 rwLock.writeLock().unlock(); **Java 中的读写锁:**ReentrantReadWriteLock 6、公平锁 公平锁是一种思想: 多个线程按照申请锁的顺序来获取锁。在并发环境中,每个线程会先查看此锁维护的等待队列,如果当前等待队列为空,则占有锁,如果等待队列不为空,则加入到等待队列的末尾,按照 FIFO 的原则从队列中拿到线程,然后占有锁。 7、非公平锁 非公平锁 非公平锁是一种思想: 线程尝试获取锁,如果获取不到,则再采用公平锁的方式。多个线程获取锁的顺序,不是按照先到先得的顺序,有可能后申请锁的线程比先申请的线程优先获取锁。 优点: 非公平锁的性能高于公平锁。 缺点: 有可能造成线程饥饿(某个线程很长一段时间获取不到锁) Java 中的非公平锁:synchronized 是非公平锁,ReentrantLock 通过构造函数指定该锁是公平的还是非公平的,默认是非公平的。 8、共享锁 共享锁是一种思想: 可以有多个线程获取读锁,以共享的方式持有锁。和乐观锁、读写锁同义。 Java 中用到的共享锁: ReentrantReadWriteLock。 9、独占锁 独占锁是一种思想: 只能有一个线程获取锁,以独占的方式持有锁。和悲观锁、互斥锁同义。 Java 中用到的独占锁: synchronized,ReentrantLock 10、重量级锁 重量级锁是一种称谓: synchronized是通过对象内部的一个叫做监视器锁(monitor)来实现的,监视器锁本身依赖底层的操作系统的 Mutex Lock来实现。操作系统实现线程的切换需要从用户态切换到核心态,成本非常高。这种依赖于操作系统 Mutex Lock来实现的锁称为重量级锁。为了优化synchonized,引入了轻量级锁,偏向锁。 Java 中的重量级锁: synchronized 11、轻量级锁 轻量级锁是 JDK6 时加入的一种锁优化机制: 轻量级锁是在无竞争的情况下使用 CAS 操作去消除同步使用的互斥量。轻量级是相对于使用操作系统互斥量来实现的重量级锁而言的。 轻量级锁在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。如果出现两条以上的线程争用同一个锁的情况,那轻量级锁将不会有效,必须膨胀为重量级锁。 优点: 如果没有竞争,通过 CAS 操作成功避免了使用互斥量的开销。 缺点: 如果存在竞争,除了互斥量本身的开销外,还额外产生了 CAS 操作的开销,因此在有竞争的情况下,轻量级锁比传统的重量级锁更慢。 12、偏向锁 偏向锁是 JDK6 时加入的一种锁优化机制: 在无竞争的情况下把整个同步都消除掉,连 CAS 操作都不去做了。偏是指偏心,它的意思是这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁一直没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。 持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(例如加锁、解锁及对 Mark Word 的更新操作等)。 优点: 把整个同步都消除掉,连 CAS 操作都不去做了,优于轻量级锁。 缺点: 如果程序中大多数的锁都总是被多个不同的线程访问,那偏向锁就是多余的。 13、分段锁 分段锁是一种机制: 最好的例子来说明分段锁是 ConcurrentHashMap。**ConcurrentHashMap 原理:**它内部细分了若干个小的 HashMap,称之为段(Segment)。默认情况下一个 ConcurrentHashMap 被进一步细分为 16 个段,既就是锁的并发度。 如果需要在 ConcurrentHashMap 添加一项 key-value,并不是将整个 HashMap 加锁,而是首先根据 hashcode 得到该 key-value 应该存放在哪个段中,然后对该段加锁,并完成 put 操作。 在多线程环境中,如果多个线程同时进行 put 操作,只要被加入的 key-value 不存放在同一个段中,则线程间可以做到真正的并行。 线程安全:ConcurrentHashMap 是一个 Segment 数组, Segment 通过继承 ReentrantLock 来进行加锁,所以每次需要加锁的操作锁住的是一个 segment,这样只要保证每个 Segment 是线程安全的,也就实现了全局的线程安全 14、互斥锁 互斥锁与悲观锁、独占锁同义,表示某个资源只能被一个线程访问,其他线程不能访问。 读-读互斥 读-写互斥 写-读互斥 写-写互斥 Java 中的同步锁: synchronized 15、同步锁 同步锁与互斥锁同义,表示并发执行的多个线程,在同一时间内只允许一个线程访问共享数据。 Java 中的同步锁: synchronized 16、死锁 死锁是一种现象:如线程 A 持有资源 x,线程 B 持有资源 y,线程 A 等待线程 B 释放资源 y,线程 B 等待线程 A 释放资源 x,两个线程都不释放自己持有的资源,则两个线程都获取不到对方的资源,就会造成死锁。 Java 中的死锁不能自行打破,所以线程死锁后,线程不能进行响应。所以一定要注意程序的并发场景,避免造成死锁。 17、锁粗化 锁粗化是一种优化技术: 如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作都是出现在循环体体之中,就算真的没有线程竞争,频繁地进行互斥同步操作将会导致不必要的性能损耗,所以就采取了一种方案:把加锁的范围扩展(粗化)到整个操作序列的外部,这样加锁解锁的频率就会大大降低,从而减少了性能损耗。 18、锁消除 锁消除是一种优化技术: 就是把锁干掉。当 Java 虚拟机运行时发现有些共享数据不会被线程竞争时就可以进行锁消除。 那如何判断共享数据不会被线程竞争? 利用逃逸分析技术:分析对象的作用域,如果对象在 A 方法中定义后,被作为参数传递到 B 方法中,则称为方法逃逸;如果被其他线程访问,则称为线程逃逸。 在堆上的某个数据不会逃逸出去被其他线程访问到,就可以把它当作栈上数据对待,认为它是线程私有的,同步加锁就不需要了。 19、synchronized synchronized是 Java 中的关键字:用来修饰方法、对象实例。属于独占锁、悲观锁、可重入锁、非公平锁。 1.作用于实例方法时,锁住的是对象的实例(this); 2.当作用于静态方法时,锁住的是 Class 类,相当于类的一个全局锁, 会锁所有调用该方法的线程; 3.synchronized 作用于一个非 NULL 的对象实例时,锁住的是所有以该对象为锁的代码块。它有多个队列,当多个线程一起访问某个对象监视器的时候,对象监视器会将这些线程存储在不同的容器中。 每个对象都有个 monitor 对象, 加锁就是在竞争 monitor 对象,代码块加锁是在代码块前后分别加上 monitorenter 和 monitorexit 指令来实现的,方法加锁是通过一个标记位来判断的。 20、Lock 和 synchronized 的区别 自动挡和手动挡的区别 Lock: 是 Java 中的接口,可重入锁、悲观锁、独占锁、互斥锁、同步锁。 1.Lock 需要手动获取锁和释放锁。就好比自动挡和手动挡的区别 2.Lock 是一个接口,而 synchronized 是 Java 中的关键字, synchronized 是内置的语言实现。 3.synchronized 在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生;而 Lock 在发生异常时,如果没有主动通过 unLock()去释放锁,则很可能造成死锁现象,因此使用 Lock 时需要在 finally 块中释放锁。 4.Lock 可以让等待锁的线程响应中断,而 synchronized 却不行,使用 synchronized 时,等待的线程会一直等待下去,不能够响应中断。 5.通过 Lock 可以知道有没有成功获取锁,而 synchronized 却无法办到。 6.Lock 可以通过实现读写锁提高多个线程进行读操作的效率。 synchronized 的优势: 足够清晰简单,只需要基础的同步功能时,用 synchronized。 Lock 应该确保在 finally 块中释放锁。如果使用 synchronized,JVM 确保即使出现异常,锁也能被自动释放。 使用 Lock 时,Java 虚拟机很难得知哪些锁对象是由特定线程锁持有的。 21、ReentrantLock 和 synchronized 的区别 ReentrantLock是 Java 中的类 : 继承了 Lock 类,可重入锁、悲观锁、独占锁、互斥锁、同步锁。 划重点 相同点: 1.主要解决共享变量如何安全访问的问题 2.都是可重入锁,也叫做递归锁,同一线程可以多次获得同一个锁, 3.保证了线程安全的两大特性:可见性、原子性。 不同点: 1.ReentrantLock 就像手动汽车,需要显示的调用 lock 和 unlock 方法, synchronized 隐式获得释放锁。 2.ReentrantLock 可响应中断, synchronized 是不可以响应中断的,ReentrantLock 为处理锁的不可用性提供了更高的灵活性 3.ReentrantLock 是 API 级别的, synchronized 是 JVM 级别的 4.ReentrantLock 可以实现公平锁、非公平锁,默认非公平锁,synchronized 是非公平锁,且不可更改。 5.ReentrantLock 通过 Condition 可以绑定多个条件

优秀的个人博客,低调大师

面试阿里被问:“你的项目是如何处理重复请求/并发请求的?”

利用唯一请求编号去重 业务参数去重 计算请求参数的摘要作为参数标识 继续优化,考虑剔除部分时间因子 请求去重工具类,Java实现 总结 对于一些用户请求,在某些情况下是可能重复发送的,如果是查询类操作并无大碍,但其中有些是涉及写入操作的,一旦重复了,可能会导致很严重的后果,例如交易的接口如果重复请求可能会重复下单。 重复的场景有可能是: 黑客拦截了请求,重放 前端/客户端因为某些原因请求重复发送了,或者用户在很短的时间内重复点击了。 网关重发 …. 本文讨论的是如何在服务端优雅地统一处理这种情况,如何禁止用户重复点击等客户端操作不在本文的讨论范畴。 利用唯一请求编号去重 你可能会想到的是,只要请求有唯一的请求编号,那么就能借用Redis做这个去重——只要这个唯一请求编号在redis存在,证明处理过,那么就认为是重复的 代码大概如下: String KEY = "REQ12343456788";//请求唯一编号 long expireTime = 1000;// 1000毫秒过期,1000ms内的重复请求会认为重复 long expireAt = System.currentTimeMillis() + expireTime; String val = "expireAt@" + expireAt; //redis key还存在的话要就认为请求是重复的 Boolean firstSet = stringRedisTemplate.execute((RedisCallback<Boolean>) connection -> connection.set(KEY.getBytes(), val.getBytes(), Expiration.milliseconds(expireTime), RedisStringCommands.SetOption.SET_IF_ABSENT)); final boolean isConsiderDup; if (firstSet != null && firstSet) {// 第一次访问 isConsiderDup = false; } else {// redis值已存在,认为是重复了 isConsiderDup = true; } 业务参数去重 上面的方案能解决具备唯一请求编号的场景,例如每次写请求之前都是服务端返回一个唯一编号给客户端,客户端带着这个请求号做请求,服务端即可完成去重拦截。 但是,很多的场景下,请求并不会带这样的唯一编号!那么我们能否针对请求的参数作为一个请求的标识呢? 先考虑简单的场景,假设请求参数只有一个字段reqParam,我们可以利用以下标识去判断这个请求是否重复。用户ID:接口名:请求参数 String KEY = "dedup:U="+userId + "M=" + method + "P=" + reqParam; 那么当同一个用户访问同一个接口,带着同样的reqParam过来,我们就能定位到他是重复的了。 但是问题是,我们的接口通常不是这么简单,以目前的主流,我们的参数通常是一个JSON。那么针对这种场景,我们怎么去重呢? 计算请求参数的摘要作为参数标识 假设我们把请求参数(JSON)按KEY做升序排序,排序后拼成一个字符串,作为KEY值呢?但这可能非常的长,所以我们可以考虑对这个字符串求一个MD5作为参数的摘要,以这个摘要去取代reqParam的位置。 String KEY = "dedup:U="+userId + "M=" + method + "P=" + reqParamMD5; 这样,请求的唯一标识就打上了! 注:MD5理论上可能会重复,但是去重通常是短时间窗口内的去重(例如一秒),一个短时间内同一个用户同样的接口能拼出不同的参数导致一样的MD5几乎是不可能的。 继续优化,考虑剔除部分时间因子 上面的问题其实已经是一个很不错的解决方案了,但是实际投入使用的时候可能发现有些问题:某些请求用户短时间内重复的点击了(例如1000毫秒发送了三次请求),但绕过了上面的去重判断(不同的KEY值)。 原因是这些请求参数的字段里面,是带时间字段的,这个字段标记用户请求的时间,服务端可以借此丢弃掉一些老的请求(例如5秒前)。如下面的例子,请求的其他参数是一样的,除了请求时间相差了一秒: //两个请求一样,但是请求时间差一秒 String req = "{\n" + "\"requestTime\" :\"20190101120001\",\n" + "\"requestValue\" :\"1000\",\n" + "\"requestKey\" :\"key\"\n" + "}"; String req2 = "{\n" + "\"requestTime\" :\"20190101120002\",\n" + "\"requestValue\" :\"1000\",\n" + "\"requestKey\" :\"key\"\n" + "}"; 这种请求,我们也很可能需要挡住后面的重复请求。所以求业务参数摘要之前,需要剔除这类时间字段。还有类似的字段可能是GPS的经纬度字段(重复请求间可能有极小的差别)。 请求去重工具类,Java实现 public class ReqDedupHelper { /** * * @param reqJSON 请求的参数,这里通常是JSON * @param excludeKeys 请求参数里面要去除哪些字段再求摘要 * @return 去除参数的MD5摘要 */ public String dedupParamMD5(final String reqJSON, String... excludeKeys) { String decreptParam = reqJSON; TreeMap paramTreeMap = JSON.parseObject(decreptParam, TreeMap.class); if (excludeKeys!=null) { List<String> dedupExcludeKeys = Arrays.asList(excludeKeys); if (!dedupExcludeKeys.isEmpty()) { for (String dedupExcludeKey : dedupExcludeKeys) { paramTreeMap.remove(dedupExcludeKey); } } } String paramTreeMapJSON = JSON.toJSONString(paramTreeMap); String md5deDupParam = jdkMD5(paramTreeMapJSON); log.debug("md5deDupParam = {}, excludeKeys = {} {}", md5deDupParam, Arrays.deepToString(excludeKeys), paramTreeMapJSON); return md5deDupParam; } private static String jdkMD5(String src) { String res = null; try { MessageDigest messageDigest = MessageDigest.getInstance("MD5"); byte[] mdBytes = messageDigest.digest(src.getBytes()); res = DatatypeConverter.printHexBinary(mdBytes); } catch (Exception e) { log.error("",e); } return res; } } 下面是一些测试日志: public static void main(String[] args) { //两个请求一样,但是请求时间差一秒 String req = "{\n" + "\"requestTime\" :\"20190101120001\",\n" + "\"requestValue\" :\"1000\",\n" + "\"requestKey\" :\"key\"\n" + "}"; String req2 = "{\n" + "\"requestTime\" :\"20190101120002\",\n" + "\"requestValue\" :\"1000\",\n" + "\"requestKey\" :\"key\"\n" + "}"; //全参数比对,所以两个参数MD5不同 String dedupMD5 = new ReqDedupHelper().dedupParamMD5(req); String dedupMD52 = new ReqDedupHelper().dedupParamMD5(req2); System.out.println("req1MD5 = "+ dedupMD5+" , req2MD5="+dedupMD52); //去除时间参数比对,MD5相同 String dedupMD53 = new ReqDedupHelper().dedupParamMD5(req,"requestTime"); String dedupMD54 = new ReqDedupHelper().dedupParamMD5(req2,"requestTime"); System.out.println("req1MD5 = "+ dedupMD53+" , req2MD5="+dedupMD54); } 日志输出: req1MD5 = 9E054D36439EBDD0604C5E65EB5C8267 , req2MD5=A2D20BAC78551C4CA09BEF97FE468A3F req1MD5 = C2A36FED15128E9E878583CAAAFEFDE9 , req2MD5=C2A36FED15128E9E878583CAAAFEFDE9 日志说明: 一开始两个参数由于requestTime是不同的,所以求去重参数摘要的时候可以发现两个值是不一样的 第二次调用的时候,去除了requestTime再求摘要(第二个参数中传入了”requestTime”),则发现两个摘要是一样的,符合预期。 总结 至此,我们可以得到完整的去重解决方案,如下: String userId= "12345678";//用户 String method = "pay";//接口名 String dedupMD5 = new ReqDedupHelper().dedupParamMD5(req,"requestTime");//计算请求参数摘要,其中剔除里面请求时间的干扰 String KEY = "dedup:U=" + userId + "M=" + method + "P=" + dedupMD5; long expireTime = 1000;// 1000毫秒过期,1000ms内的重复请求会认为重复 long expireAt = System.currentTimeMillis() + expireTime; String val = "expireAt@" + expireAt; // NOTE:直接SETNX不支持带过期时间,所以设置+过期不是原子操作,极端情况下可能设置了就不过期了,后面相同请求可能会误以为需要去重,所以这里使用底层API,保证SETNX+过期时间是原子操作 Boolean firstSet = stringRedisTemplate.execute((RedisCallback<Boolean>) connection -> connection.set(KEY.getBytes(), val.getBytes(), Expiration.milliseconds(expireTime), RedisStringCommands.SetOption.SET_IF_ABSENT)); final boolean isConsiderDup; if (firstSet != null && firstSet) { isConsiderDup = false; } else { isConsiderDup = true; } Java 的知识面非常广,面试问的涉及也非常广泛,重点包括:Java 基础、Java 并发,JVM、MySQL、数据结构、算法、Spring、微服务、MQ 等等,涉及的知识点何其庞大,所以我们在复习的时候也往往无从下手,今天小编给大家带来一套 Java 面试题,题库非常全面,包括 Java 基础、Java 集合、JVM、Java 并发、Spring全家桶、Redis、MySQL、Dubbo、Netty、MQ 等等,包含 Java 后端知识点 2000 + 资料获取方式:关注公众号:“程序员白楠楠”获取

优秀的个人博客,低调大师

阿里面试官:HashMap 熟悉吧?好的,那就来聊聊 Redis 字典吧!

每天早上七点三十,准时推送干货 最近,阿粉的一个朋友出去面试,回来跟阿粉抱怨,面试官不按套路出牌,直接打乱了他的节奏。 事情是这样的,前面面试问了几个 Java 的相关问题,我朋友回答还不错,接下来面试官就问了一句:看来 Java 基础还不错,Java HashMap 你熟悉吧? 我朋友回答。工作经常用,有看过源码。 我朋友本来想着,你随便来吧,这个问题之前已经准备好了,随便问吧。 谁知道,面试官下面一句: 「那好的,我们来聊聊 Redis 字典吧。」 直接将他整蒙逼。 阿粉的朋友由于没怎么研究过 Redis 字典,所以这题就直接回答不知道了。 「当然,如果面试中真不知道,那就回答不了解,直接下一题,不要乱答。」 不过这一题,阿粉觉得还是很可惜,其实 Redis 字典基本原理与 HashMap 差不多,那我们其实可以套用这其中的原理,不求回答满分,但是怎么也可以得个及格分吧~ 面试过程真要碰到这个问题,我们可以从下面三个方面回答。 数据结构 元素增加过程 扩容 字典数据结构 说起字典,也许大家比较陌生,但是我们都知道 Redis 本身提供 KV 查询的方式,这个 KV 就是其实通过底层就是通过字典保存。 另外,Redis 支持多种数据类型,其中一种类型为 Hash 键,也可以用来存储 KV 数据。 阿粉刚开始了解的这个数据结构的时候,本来以为这个就是使用字典实现。其实并不是这样的,初始创建 Hash 键,默认使用另外一种数据结构-「ZIPLIST」(压缩列表),以此节省内存空间。 不过一旦以下任何条件被满足,Hash 键的数据结构将会变为字典,加快查询速度。 哈希表中某个键或某个值的长度大于 server.hash_max_ziplist_value (默认值为 64 )。 压缩列表中的节点数量大于 server.hash_max_ziplist_entries (默认值为 512 )。 Redis 字典新建时默认将会创建一个哈希表数组,保存两个哈希表。 其中 ht[0] 哈希表在第一次往字典中添加键值时分配内存空间,而另一个 ht[1] 将会在下文中扩容/缩容才会进行空间分配。 字典中哈希表其实就等同于Java HashMap,我们知道 Java 采用数组加链表/红黑树的实现方式,其实哈希表也是使用类似的数据结构。 哈希表结构如下所示: 其中 table 属性是个数组, 其中数组元素保存一种 dictEntry 的结构,这个结构完全类似与 HashMap 中的 Entry 类型,这个结构存储一个 KV 键值对。 同时,为了解决 hash 碰撞的问题,dictEntry 存在一个 next 指针,指向下一个dictEntry ,这样就形成 dictEntry 的链表。 现在,我们回头对比 Java 中 HashMap,可以发现两者数据结构基本一致。 只不过 HashMap 为了解决链表过长问题导致查询变慢,JDK1.8 时在链表元素过多时采用红黑树的数据结构。 下面我们开始添加新元素,了解这其中的原理。 元素增加过程 当我们往一个新字典中添加元素,默认将会为字典中 ht[0] 哈希表分配空间,默认情况下哈希表 table 数组大小为 4(「DICT_HT_INITIAL_SIZE」)。 新添加元素的键值将会经过哈希算法,确定哈希表数组的位置,然后添加到相应的位置,如图所示: 继续增加元素,此时如果两个不同键经过哈希算法产生相同的哈希值,这样就发生了哈希碰撞。 假设现在我们哈希表中拥有是三个元素,: 我们再增加一个新元素,如果此时刚好在数组 3 号位置上发生碰撞,此时 Redis 将会采用链表的方式解决哈希碰撞。 「注意,新元素将会放在链表头结点,这么做目的是因为新增加的元素,很大概率上会被再次访问,放在头结点增加访问速度。」 这里我们在对比一下元素添加过程,可以发现 Redis 流程其实与 JDK 1.7 版本的 HashMap 类似。 当我们元素增加越来越多时,哈希碰撞情况将会越来越频繁,这就会导致链表长度过长,极端情况下 O(1) 查询效率退化成 O(N) 的查询效率。 为此,字典必须进行扩容,这样就会使触发字典 rehash 操作。 扩容 当 Redis 进行 Rehash 扩容操作,首先将会为字典没有用到 ht[1] 哈希表分配更大空间。 ❝ 画外音:ht[1] 哈希表大小为第一个大于等于 ht[0].used*2 的 2^2(2的n 次方幂) ❞ 然后再将 ht[0] 中所有键值对都迁移到 ht[1] 中。 简单起见,忽略指向空节点 当节点全部迁移完毕,将会释放 ht[0]占用空间,并将 ht[1] 设置为 ht[0]。 扩容 操作需要将 ht[0]所有键值对都 Rehash 到 ht[1] 中,如果键值过多,假设存在十亿个键值对,这样一次性的迁移,势必导致服务器会在一段时间内停止服务。 另外如果每次 rehash 都会阻塞当前操作,这样对于客户端处理非常不友好。 为了避免 rehash对服务器的影响,Redis 采用渐进式的迁移方式,慢慢将数据迁移分散到多个操作步骤。 这个操作依赖字典中一个属性 rehashidx,这是一个索引位置计数器,记录下一个哈希表 table 数组上元素,默认情况为值为 「-1」。 假设此时扩容前字典如图所示: 当开始 rehash 操作,rehashidx将会被设置为 「0」 。 这个期间每次收到增加,删除,查找,更新命令,除了这些命令将会被执行以外,还会顺带将 ht[0]哈希表在 rehashidx 位置的元素 rehash 到 ht[1] 中。 假设此时收到一个 「K3」 键的查询操作,Redis 首先执行查询操作,接着 Redis 将会为 ht[0]哈希表上table 数组第 rehashidx索引上所有节点都迁移到 ht[1] 中。 当操作完成之后,再将 rehashidx 属性值加 1。 最后当所有键值对都 rehash 到 ht[1]中时,rehashidx将会被重新设置为 -1。 虽然渐进式的 rehash 操作减少了工作量,但是却带来键值操作的复杂度。 这是因为在渐进式 rehash 操作期间,Redis 无法明确知道键到底在 ht[0]中,还是在 ht[1] 中,所以这个时候 Redis 不得不查找两个哈希表。 以查找为例,Redis 首先查询 ht[0] ,如果没找到将会继续查找 ht[1],除了查询以外,更新,删除也会执行如上的操作。 添加操作其实就没这么麻烦,因为ht[0]不会在使用,那就统一都添加到 ht[1] 中就好了。 最后我们再对比一下 Java HashMap 扩容操作,它是一个一次性操作,每次扩容需要将所有键值对都迁移到新的数组中,所以如果数据量很大,消耗时间就会久。 总结 Redis 字典使用哈希表作为底层实现,每个字典包含两个哈希表,一个平时使用,一个仅在 rehash 操作中使用。 哈希表总的来说,跟 Java HashMap 真的很类似,底层实现也是一个数组加链表数据结构。 最后,当对哈希表进行扩容操作时间,将会采用渐进性 rehash 操作,慢慢将所有键值对迁移到新哈希表中。 其实了解 Redis 字典的其中的原理,再去比较 Java HashMap ,其实可以发现这两者有如此多的相似点。 所以学习这类知识时,不要仅仅去背,我们要了解其底层原理,知其然知其所以然。 帮助资料 https://redisbook.readthedocs.io/en/latest/internal-datastruct/dict.html < END > 如果大家喜欢我们的文章,欢迎大家转发,点击在看让更多的人看到。也欢迎大家热爱技术和学习的朋友加入的我们的知识星球当中,我们共同成长,进步。 往期 精彩回顾 后端的Long型参数,让阿粉踩了个大坑 SpringBoot2.x 整合 shiro 权限框架 什么是BIO,NIO?他们和多路复用器有啥关系? 对了,看完记得一键三连,这个对我真的很重要。 本文分享自微信公众号 - Java极客技术(Javageektech)。如有侵权,请联系 support@oschina.cn 删除。本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

资源下载

更多资源
Mario

Mario

马里奥是站在游戏界顶峰的超人气多面角色。马里奥靠吃蘑菇成长,特征是大鼻子、头戴帽子、身穿背带裤,还留着胡子。与他的双胞胎兄弟路易基一起,长年担任任天堂的招牌角色。

Spring

Spring

Spring框架(Spring Framework)是由Rod Johnson于2002年提出的开源Java企业级应用框架,旨在通过使用JavaBean替代传统EJB实现方式降低企业级编程开发的复杂性。该框架基于简单性、可测试性和松耦合性设计理念,提供核心容器、应用上下文、数据访问集成等模块,支持整合Hibernate、Struts等第三方框架,其适用范围不仅限于服务器端开发,绝大多数Java应用均可从中受益。

Rocky Linux

Rocky Linux

Rocky Linux(中文名:洛基)是由Gregory Kurtzer于2020年12月发起的企业级Linux发行版,作为CentOS稳定版停止维护后与RHEL(Red Hat Enterprise Linux)完全兼容的开源替代方案,由社区拥有并管理,支持x86_64、aarch64等架构。其通过重新编译RHEL源代码提供长期稳定性,采用模块化包装和SELinux安全架构,默认包含GNOME桌面环境及XFS文件系统,支持十年生命周期更新。

WebStorm

WebStorm

WebStorm 是jetbrains公司旗下一款JavaScript 开发工具。目前已经被广大中国JS开发者誉为“Web前端开发神器”、“最强大的HTML5编辑器”、“最智能的JavaScript IDE”等。与IntelliJ IDEA同源,继承了IntelliJ IDEA强大的JS部分的功能。

用户登录
用户注册