1.synchronized底层原理是什么?
synchronized有两种形式上锁,一个是对方法上锁,一个是构造同步代码块。他们的底层实现其实都一样,在进入同步代码之前先获取锁,获取到锁之后锁的计数器+1,同步代码执行完锁的计数器-1,如果获取失败就阻塞式等待锁的释放。只是他们在同步块识别方式上有所不一样,从class字节码文件可以表现出来,一个是通过方法flags标志,一个是monitorenter和monitorexit指令操作。
1.1 同步方法
首先来看在方法上上锁,我们就新定义一个同步方法然后进行反编译,查看其字节码:
可以看到在add方法的flags里面多了一个ACC_SYNCHRONIZED
标志,这标志用来告诉JVM这是一个同步方法,在进入该方法之前先获取相应的锁,锁的计数器加1,方法结束后计数器-1,如果获取失败就阻塞住,知道该锁被释放。
1.2 同步代码块
我们新定义一个同步代码块,编译出class字节码,然后找到method方法所在的指令块,可以清楚的看到其实现上锁和释放锁的过程,截图如下:
从反编译的同步代码块可以看到同步块是由monitorenter指令进入,然后monitorexit释放锁,在执行monitorenter之前需要尝试获取锁,如果这个对象没有被锁定,或者当前线程已经拥有了这个对象的锁,那么就把锁的计数器加1。当执行monitorexit指令时,锁的计数器也会减1。当获取锁失败时会被阻塞,一直等待锁被释放。
但是为什么会有两个monitorexit呢?其实第二个monitorexit是来处理异常的,仔细看反编译的字节码,正常情况下第一个monitorexit之后会执行goto
指令,而该指令转向的就是23行的return
,也就是说正常情况下只会执行第一个monitorexit释放锁,然后返回。而如果在执行中发生了异常,第二个monitorexit就起作用了,它是由编译器自动生成的,在发生异常时处理异常然后释放掉锁。
synchronized锁的底层实现
要想理清synchronized的锁的原理,需要掌握两个重要的概念:
- 对象头
- monitor
在理解锁实现原理之前先了解一下Java的对象头和Monitor,在JVM中,对象是分成三部分存在的:对象头、实例数据、对其填充。
java对象头
在Hotspot虚拟机中,对象在内存中的存储布局,可以分为三块:对象头Header,实例数据Instance Data,对齐填充Padding。
Hotspot虚拟机的对象头包含了两部分信息:
- Mark Word,用于存储对象自身的运行时数据,比如hash,gc分代年龄,锁状态的标志,线程持有锁,偏向ID,偏向时间戳等等
- Klass Pointer:对象指向它的类的元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
32位HotSpot虚拟机的对象头存储结构如下
。
所以,对象头中的Mark Word,synchronized源码就是用了对象头中的Mark Word来标识对象加锁状态。
monitor
Monitor Record是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor record关联(对象头的MarkWord中的LockWord指向monitor record的起始地址),同时monitor record中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。如下图所示为Monitor Record的内部结构
线程唯一标识,当锁被释放时又设置为NULL; EntryQ:关联一个系统互斥锁(semaphore),阻塞所有试图锁住monitor record失败的线程。 RcThis:表示blocked或waiting在该monitor record上的所有线程的个数。 Nest:用来实现重入锁的计数。 HashCode:保存从对象头拷贝过来的HashCode值(可能还包含GC age)。
Candidate:用来避免不必要的阻塞或等待线程唤醒,因为每一次只有一个线程能够成功拥有锁,如果每次前一个释放锁的线程唤醒所有正在阻塞或等待的线程,会引起不必要的上下文切换(从阻塞到就绪然后因为竞争锁失败又被阻塞)从而导致性能严重下降。Candidate只有两种可能的值0表示没有需要唤醒的线程1表示要唤醒一个继任线程来竞争
总结
简单总结一下,同步块使用monitorenter和monitorexit指令,而同步方法是依靠方法修饰符上的flag——ACC_SYNCHRONIZED来完成的。其本质都是对一个对象监视器monitor进行获取,这个获取过程是排他的,也就是同一时刻只能有一个线程获得由synchronized所保护的对象的监视器。而这个监视器,也可以理解为一个同步工具,它是由java对象进行描述的,在Hotspor中,是通过ObjectMonitor来实现,每个对象中天然都内置了一个ObjectMonitor对象。
在java中,synchronized在编译后,会在同步块的前后分别形成一个monitorenter和monitorexit这两个字节码指令,这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象,如果java程序中明确指定了对象,那就是这个对象的reference,如果没有指明,那么根据synchronized修饰的是实例方法还是类方法,去取对应的对象实例或者类Class对象来做锁对象。
在执行monitorenter时,首先会尝试获取对象的锁,如果这个对象没有锁,或者当前线程已经拥有了这个对象的锁,那个锁的计数器加1,相应的,在执行monitorexit时指令时,会将锁计数器减1,当计数器为0时,这个锁就被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到对象锁被另一个线程释放为止。
JVM对synchronized的优化
从最近几个jdk版本中可以看出,Java的开发团队一直在对synchronized优化,其中最大的一次优化就是在jdk6的时候,新增了两个锁状态,通过锁消除、锁粗化、自旋锁等方法使用各种场景,给synchronized性能带来了很大的提升。
5.1 锁膨胀
上面讲到锁有四种状态,并且会因实际情况进行膨胀升级,其膨胀方向是:无锁——>偏向锁——>轻量级锁——>重量级锁,并且膨胀方向不可逆。
5.1.1 偏向锁
一句话总结它的作用:减少统一线程获取锁的代价。在大多数情况下,锁不存在多线程竞争,总是由同一线程多次获得,那么此时就是偏向锁。
核心思想:
如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word
的结构也就变为偏向锁结构,当该线程再次请求锁时,无需再做任何同步操作,即获取锁的过程只需要检查Mark Word
的锁标记位为偏向锁以及当前线程ID等于Mark Word
的ThreadID即可,这样就省去了大量有关锁申请的操作。
5.1.2 轻量级锁
轻量级锁是由偏向锁升级而来,当存在第二个线程申请同一个锁对象时,偏向锁就会立即升级为轻量级锁。注意这里的第二个线程只是申请锁,不存在两个线程同时竞争锁,可以是一前一后地交替执行同步块。
5.1.3 重量级锁
重量级锁是由轻量级锁升级而来,当同一时间有多个线程竞争锁时,锁就会被升级成重量级锁,此时其申请锁带来的开销也就变大。
重量级锁一般使用场景会在追求吞吐量,同步块或者同步方法执行时间较长的场景。
5.2 锁消除
消除锁是虚拟机另外一种锁的优化,这种优化更彻底,在JIT编译时,对运行上下文进行扫描,去除不可能存在竞争的锁。比如下面代码的method1和method2的执行效率是一样的,因为object锁是私有变量,不存在所得竞争关系。
5.3 锁粗化
锁粗化是虚拟机对另一种极端情况的优化处理,通过扩大锁的范围,避免反复加锁和释放锁。比如下面method3经过锁粗化优化之后就和method4执行效率一样了。
5.4 自旋锁与自适应自旋锁
轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。
自旋锁:许多情况下,共享数据的锁定状态持续时间较短,切换线程不值得,通过让线程执行循环等待锁的释放,不让出CPU。如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式。但是它也存在缺点:如果锁被其他线程长时间占用,一直不释放CPU,会带来许多的性能开销。
自适应自旋锁:这种相当于是对上面自旋锁优化方式的进一步优化,它的自旋的次数不再固定,其自旋的次数由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定,这就解决了自旋锁带来的缺点。
synchronized与Lock的区别
类别 | synchronized | Lock |
---|---|---|
存在层次 | Java的关键字,在jvm层面上 | 是一个类 |
锁的释放 | 1、以获取锁的线程执行完同步代码,释放锁 2、线程执行发生异常,jvm会让线程释放锁 | 在finally中必须释放锁,不然容易造成线程死锁 |
锁的获取 | 假设A线程获得锁,B线程等待。如果A线程阻塞,B线程会一直等待 | 分情况而定,Lock有多个锁获取的方式,具体下面会说道,大致就是可以尝试获得锁,线程可以不用一直等待 |
锁状态 | 无法判断 | 可以判断 |
锁类型 | 可重入 不可中断 非公平 | 可重入 可判断 可公平(两者皆可) |
性能 | 少量同步 | 大量同步 |
一、重量级锁
上篇文章中向大家介绍了Synchronized的用法及其实现的原理。现在我们应该知道,Synchronized是通过对象内部的一个叫做监视器锁(monitor)来实现的。但是监视器锁本质又是依赖于底层的操作系统的Mutex Lock来实现的。而操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因。因此,这种依赖于操作系统Mutex Lock所实现的锁我们称之为“重量级锁”。JDK中对Synchronized做的种种优化,其核心都是为了减少这种重量级锁的使用。JDK1.6以后,为了减少获得锁和释放锁所带来的性能消耗,提高性能,引入了“轻量级锁”和“偏向锁”。
二、轻量级锁
锁的状态总共有四种:无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁(但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级)。JDK 1.6中默认是开启偏向锁和轻量级锁的,我们也可以通过-XX:-UseBiasedLocking来禁用偏向锁。锁的状态保存在对象的头文件中,以32位的JDK为例:
锁状态 | 25 bit | 4bit | 1bit | 2bit | ||
23bit | 2bit | 是否是偏向锁 | 锁标志位 | |||
轻量级锁 | 指向栈中锁记录的指针 | 00 | ||||
重量级锁 | 指向互斥量(重量级锁)的指针 | 10 | ||||
GC标记 | 空 | 11 | ||||
偏向锁 | 线程ID | Epoch | 对象分代年龄 | 1 | 01 | |
无锁 | 对象的hashCode | 对象分代年龄 | 0 | 01 |
“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的。但是,首先需要强调一点的是,轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用产生的性能消耗。在解释轻量级锁的执行过程之前,先明白一点,轻量级锁所适应的场景是线程交替执行同步块的情况,如果存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁。
1、轻量级锁的加锁过程
(1)在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为 Displaced Mark Word。这时候线程堆栈与对象头的状态如图2.1所示。
(2)拷贝对象头中的Mark Word复制到锁记录中。
(3)拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock record里的owner指针指向object mark word。如果更新成功,则执行步骤(3),否则执行步骤(4)。
(4)如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如图2.2所示。
(5)如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。 而当前线程便尝试使用自旋来获取锁,自旋就是为了不让线程阻塞,而采用循环去获取锁的过程。
图2.1 轻量级锁CAS操作之前堆栈与对象的状态
图2.2 轻量级锁CAS操作之后堆栈与对象的状态
2、轻量级锁的解锁过程:
(1)通过CAS操作尝试把线程中复制的Displaced Mark Word对象替换当前的Mark Word。
(2)如果替换成功,整个同步过程就完成了。
(3)如果替换失败,说明有其他线程尝试过获取该锁(此时锁已膨胀),那就要在释放锁的同时,唤醒被挂起的线程。
三、偏向锁
引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令(由于一旦出现多线程竞争的情况就必须撤销偏向锁,所以偏向锁的撤销操作的性能损耗必须小于节省下来的CAS原子指令的性能消耗)。上面说过,轻量级锁是为了在线程交替执行同步块时提高性能,而偏向锁则是在只有一个线程执行同步块时进一步提高性能。
1、偏向锁获取过程:
(1)访问Mark Word中偏向锁的标识是否设置成1,锁标志位是否为01——确认为可偏向状态。
(2)如果为可偏向状态,则测试线程ID是否指向当前线程,如果是,进入步骤(5),否则进入步骤(3)。
(3)如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,然后执行(5);如果竞争失败,执行(4)。
(4)如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。
(5)执行同步代码。
2、偏向锁的释放:
偏向锁的撤销在上述第四步骤中有提到。偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为“01”)或轻量级锁(标志位为“00”)的状态。
3、重量级锁、轻量级锁和偏向锁之间转换
图 2.3三者的转换图
该图主要是对上述内容的总结,如果对上述内容有较好的了解的话,该图应该很容易看懂。
四、其他优化
1、适应性自旋(Adaptive Spinning):从轻量级锁获取的流程中我们知道,当线程在获取轻量级锁的过程中执行CAS操作失败时,是要通过自旋来获取重量级锁的。问题在于,自旋是需要消耗CPU的,如果一直获取不到锁的话,那该线程就一直处在自旋状态,白白浪费CPU资源。解决这个问题最简单的办法就是指定自旋的次数,例如让其循环10次,如果还没获取到锁就进入阻塞状态。但是JDK采用了更聪明的方式——适应性自旋,简单来说就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。
2、锁粗化(Lock Coarsening):锁粗化的概念应该比较好理解,就是将多次连接在一起的加锁、解锁操作合并为一次,将多个连续的锁扩展成一个范围更大的锁。举个例子:
package com.paddx.test.string;
public class StringBufferTest {
StringBuffer stringBuffer = new StringBuffer();
public void append(){
stringBuffer.append("a");
stringBuffer.append("b");
stringBuffer.append("c");
}
}
这里每次调用stringBuffer.append方法都需要加锁和解锁,如果虚拟机检测到有一系列连串的对同一个对象加锁和解锁操作,就会将其合并成一次范围更大的加锁和解锁操作,即在第一次append方法时进行加锁,最后一次append方法结束后进行解锁。
3、锁消除(Lock Elimination):锁消除即删除不必要的加锁操作。根据代码逃逸技术,如果判断到一段代码中,堆上的数据不会逃逸出当前线程,那么可以认为这段代码是线程安全的,不必要加锁。看下面这段程序:
package com.paddx.test.concurrent;
public class SynchronizedTest02 {
public static void main(String[] args) {
SynchronizedTest02 test02 = new SynchronizedTest02();
//启动预热
for (int i = 0; i < 10000; i++) {
i++;
}
long start = System.currentTimeMillis();
for (int i = 0; i < 100000000; i++) {
test02.append("abc", "def");
}
System.out.println("Time=" + (System.currentTimeMillis() - start));
}
public void append(String str1, String str2) {
StringBuffer sb = new StringBuffer();
sb.append(str1).append(str2);
}
}
虽然StringBuffer的append是一个同步方法,但是这段程序中的StringBuffer属于一个局部变量,并且不会从该方法中逃逸出去,所以其实这过程是线程安全的,可以将锁消除。下面是我本地执行的结果:
为了尽量减少其他因素的影响,这里禁用了偏向锁(-XX:-UseBiasedLocking)。通过上面程序,可以看出消除锁以后性能还是有比较大提升的。
注:可能JDK各个版本之间执行的结果不尽相同,我这里采用的JDK版本为1.6。
五、总结
本文重点介绍了JDk中采用轻量级锁和偏向锁等对Synchronized的优化,但是这两种锁也不是完全没缺点的,比如竞争比较激烈的时候,不但无法提升效率,反而会降低效率,因为多了一个锁升级的过程,这个时候就需要通过-XX:-UseBiasedLocking来禁用偏向锁。下面是这几种锁的对比:
锁 | 优点 | 缺点 | 适用场景 |
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距。 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗。 | 适用于只有一个线程访问同步块场景。 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度。 | 如果始终得不到锁竞争的线程使用自旋会消耗CPU。 | 追求响应时间。 同步块执行速度非常快。 |
重量级锁 | 线程竞争不使用自旋,不会消耗CPU。 | 线程阻塞,响应时间缓慢。 | 追求吞吐量。 同步块执行速度较长。 |
synchronized 重量级锁
1.6版本之前 synchronized 被称之为 重量级锁
1.6版本对 synchronized 进行了优化,主要优化的点在于 减少 获得锁和释放锁带
来的性能消耗,为实现这个目的引入了偏向锁、与轻量级锁。
synchronized 实现同步的基础
Java中每一个对象都可以作为锁。
普通同步方法,锁是当前实例对象。
静态同步方法块,锁是当前类的Class对象。
对于同步方法块,锁是synchronized括号里配置的对象。
synchronized 同步锁的获取 底层原理
如下图所示:
synchronized 同步锁的获取 底层原理.png
synchronized锁的存储位置
synchronized 用的锁是存在java对象头里的。
对象头中的Mark-word 默认存储对象的hashcode、分代年龄、和锁标志位。
Mark-word 中存储的数据会随着锁标志位的变化而变化
轻量级锁-00
重量级锁-10
GC标记-11
偏向锁-01
锁的升级与对比
Java SE 1.6 当中锁一共有4种状态,级别从低到高一次为:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态。锁可以升级但不能降级,这种锁升级但不能降级的策略,目的是为了提高获得锁与释放锁的效率。
1.偏向锁原理
偏向锁的优点及初始化过程:
- 加锁解锁不需要额外的资源消耗,只需要对比当前线程id在对象头中是否存储指向当前线程的偏向锁。如果存在,表示当前线程已经获得锁。如果测试失败,则查询当前mark word中的偏向锁标志是否设置成为1,如果没有设置,则使用CAS竞争锁(非偏向锁状态),如果设置了偏向锁标志,则尝试使用CAS将对象头的偏向锁指向当前线程。
偏向锁的撤销:
- 偏向锁使用了一种等到竞争出现才释放锁的机制,当其他线程尝试竞争偏向锁的时候,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点,这个时间点上没有正在执行的字节码。它首先会暂停拥有偏向锁的线程,然后检查持有偏向锁的线程活着,如果线程不处于活动状态,则将对象头设置为无锁状态。如果线程仍活着,拥有偏向锁的栈会被执行完。
2.轻量级锁原理
轻量级锁加锁:
- 线程在执行同步块之前,JVM会先在当前线程的栈桢中创建存储锁记录的空间,并将对象头中的mark word 复制到锁记录当中,然后线程尝试使用CAS将对象头中的 mark word 替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋锁来获取锁。
轻量级锁解锁
- 轻量级解锁时,会使用原子的CAS将锁记录(Displaced Mark Word)替换回到
对象头,如果成功,则表示没有发生竞争。如果失败,表示当前锁存在竞争,锁就会膨胀为重量级锁。
总结
锁 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁都不需要额外的消耗,和执行非同步的方法相比只存在纳秒级差距 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 | 适用于只有一个线程访问同步块的场景 |
轻量级锁 | 竞争的线程不会阻塞,提高了应用的相应速率 | 如果始终得不到竞争的线程,适用自旋会消耗cpu,造成cpu空转 | 追求响应时间,同步块执行速度非常快 |
重量级锁 | 线程竞争不使用自旋,不会消耗cpu | 线程阻塞,响应时间缓慢 | 追求吞吐量,同步块执行速度较长 |
不同锁的优缺点
锁 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁都不需要额外的消耗,和执行非同步的方法相比只存在纳秒级差距 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 | 适用于只有一个线程访问同步块的场景 |
轻量级锁 | 竞争的线程不会阻塞,提高了应用的相应速率 | 如果始终得不到竞争的线程,适用自旋会消耗cpu,造成cpu空转 | 追求响应时间,同步块执行速度非常快 |
重量级锁 | 线程竞争不使用自旋,不会消耗cpu |
2.redis的底层数据结构?
在Redis中,只有在使用到不会被修改的字符串字面量时(比如打印日志),Redis才会采用c语言传统的字符串(以空字符结尾的字符数组),而在Redis数据库中,所有的字符串在底层都由SDS来实现的。
底层数据结构分析
SDS是redis中的一种数据结构,叫做简单动态字符串(Simple Dynamic String),并且它是一种二进制安全的,在大多数的情况下redis中的字符串都用SDS来存储。
SDS的数据结构:
struct sdshdr {
#记录buff数组中已使用字节的数量
#也是SDS所保存字符串的长度
int len;
#记录buff数组中未使用字节的数量
int free;
#字节数组,字符串就存储在这个数组里
char buff[]; }
数据存储示例:
SDS的优点:
- 时间复杂度为O(1)
- 杜绝缓冲区溢出
- 减少修改字符串长度时候所需的内存重分配次数
- 二进制安全的API操作
- 兼容部分C字符串函数
关于SDS的详细介绍请大家参阅《redis设计与实现》一文。
redis中的位数组采用的是String字符串数据格式来存储,而字符串对象使用的正是上文说的SDS简单动态字符串数据结构。
动态字符串SDS
SDS是”simple dynamic string”的缩写。
redis中所有场景中出现的字符串,基本都是由SDS来实现的
- 所有非数字的key。例如
set msg "hello world"
中的key msg. - 字符串数据类型的值。例如`` set msg “hello world”中的msg的值”hello wolrd”
- 非字符串数据类型中的“字符串值”。例如
RPUSH fruits "apple" "banana" "cherry"
中的”apple” “banana” “cherry”
SDS长这样:
free:还剩多少空间
len:字符串长度
buf:存放的字符数组
空间预分配
为减少修改字符串带来的内存重分配次数,sds采用了“一次管够”的策略:
- 若修改之后sds长度小于1MB,则多分配现有len长度的空间
- 若修改之后sds长度大于等于1MB,则扩充除了满足修改之后的长度外,额外多1MB空间
惰性空间释放
为避免缩短字符串时候的内存重分配操作,sds在数据减少时,并不立刻释放空间。
int
就是redis中存放的各种数字
包括一下这种,故意加引号“”的
双向链表
长这样:
分两部分,一部分是“统筹部分”:橘黄色,一部分是“具体实施方“:蓝色。
主体”统筹部分“:
head
指向具体双向链表的头tail
指向具体双向链表的尾len
双向链表的长度
具体”实施方”:一目了然的双向链表结构,有前驱pre
有后继next
由list
和listNode
两个数据结构构成。
ziplist
压缩列表。
redis的列表键和哈希键的底层实现之一。此数据结构是为了节约内存而开发的。和各种语言的数组类似,它是由连续的内存块组成的,这样一来,由于内存是连续的,就减少了很多内存碎片和指针的内存占用,进而节约了内存。
然后文中的entry
的结构是这样的:
元素的遍历
先找到列表尾部元素:
然后再根据ziplist节点元素中的previous_entry_length
属性,来逐个遍历:
连锁更新
再次看看entry
元素的结构,有一个previous_entry_length
字段,他的长度要么都是1个字节,要么都是5个字节:
- 前一节点的长度小于254字节,则
previous_entry_length
长度为1字节 - 前一节点的长度小于254字节,则
previous_entry_length
长度为5字节
假设现在存在一组压缩列表,长度都在250字节至253字节之间,突然新增一新节点new
,
长度大于等于254字节,会出现:
程序需要不断的对压缩列表进行空间重分配工作,直到结束。
除了增加操作,删除操作也有可能带来“连锁更新”。
请看下图,ziplist中所有entry节点的长度都在250字节至253字节之间,big节点长度大于254字节,small节点小于254字节。
哈希表
哈希表略微有点复杂。哈希表的制作方法一般有两种,一种是:开放寻址法
,一种是拉链法
。redis的哈希表的制作使用的是拉链法
。
整体结构如下图:
也是分为两部分:左边橘黄色部分和右边蓝色部分,同样,也是”统筹“和”实施“的关系。
具体哈希表的实现,都是在蓝色部分实现的。
先来看看蓝色部分:
这也分为左右两边“统筹”和“实施”的两部分。
右边部分很容易理解:就是通常拉链表实现的哈希表的样式;数组就是bucket,一般不同的key首先会定位到不同的bucket,若key重复,就用链表把冲突的key串起来。
新建key的过程:
假如重复了:
rehash
再来看看哈希表总体图中左边橘黄色的“统筹”部分,其中有两个关键的属性:ht
和rehashidx
。ht
是一个数组,有且只有俩元素ht[0]和ht[1];其中,ht[0]存放的是redis中使用的哈希表,而ht[1]和rehashidx和哈希表的rehash
有关。
rehash
指的是重新计算键的哈希值和索引值,然后将键值对重排的过程。
加载因子(load factor) = ht[0].used / ht[0].size
。
扩容和收缩标准
扩容:
- 没有执行BGSAVE和BGREWRITEAOF指令的情况下,哈希表的
加载因子
大于等于1。 - 正在执行BGSAVE和BGREWRITEAOF指令的情况下,哈希表的
加载因子
大于等于5。
收缩:
加载因子
小于0.1时,程序自动开始对哈希表进行收缩操作。
扩容和收缩的数量
扩容:
- 第一个大于等于
ht[0].used * 2
的2^n
(2的n次方幂)。
收缩:
- 第一个大于等于
ht[0].used
的2^n
(2的n次方幂)。
(以下部分属于细节分析,可以跳过直接看扩容步骤)
对于收缩,我当时陷入了疑虑:收缩标准是加载因子
小于0.1的时候,也就是说假如哈希表中有4个元素的话,哈希表的长度只要大于40,就会进行收缩,假如有一个长度大于40,但是存在的元素为4即(ht[0].used
为4)的哈希表,进行收缩,那收缩后的值为多少?
我想了一下:按照前文所讲的内容,应该是4。
但是,假如是4,存在和收缩后的长度相等,是不是又该扩容?
翻开源码看看:
收缩具体函数:
| |
| |
| |
由代码我们可以看到,假如收缩后长度为4,不仅不会收缩,甚至还会报错。(😝)
我们回过头来再看看设定:题目可能成立吗?
哈希表的扩容都是2倍增长的,最小是4,
4 ===》 8 ====》 16 =====》 32 ======》 64 ====》 128
也就是说:不存在长度为 40多的情况,只能是64。但是如果是64的话,64 X 0.1(收缩界限)= 6.4 ,也就是说在减少到6的时候,哈希表就会收缩,会缩小到多少呢?是8。此时,再继续减少到4,也不会再收缩了。所以,根本不存在一个长度大于40,但是存在的元素为4的哈希表的。
扩容步骤
收缩步骤
渐进式refresh
在”扩容步骤”和”收缩步骤” 两幅动图中每幅图的第四步骤“将ht[0]中的数据利用哈希函数重新计算,rehash到ht[1]”,并不是一步完成的,而是分成N多步,循序渐进的完成的。
因为hash中有可能存放几千万甚至上亿个key,毕竟Redis中每个hash中可以存2^32 - 1
键值对(40多亿),假如一次性将这些键值rehash的话,可能会导致服务器在一段时间内停止服务,毕竟哈希函数就得计算一阵子呢((#^.^#))。
哈希表的refresh是分多次、渐进式进行的。
渐进式refresh和下图中左边橘黄色的“统筹”部分中的rehashidx
密切相关:
- rehashidx 的数值就是现在rehash的元素位置
- rehashidx 等于 -1 的时候说明没有在进行refresh
甚至在进行期间,每次对哈希表的增删改查操作,除了正常执行之外,还会顺带将ht[0]哈希表相关键值对rehash到ht[1]。
以扩容步骤为例:
intset
整数集合是集合键的底层实现方式之一。
跳表
跳表这种数据结构长这样:
redis中把跳表抽象成如下所示:
看这个图,左边“统筹”,右边实现。
统筹部分有以下几点说明:
- header: 跳表表头
- tail:跳表表尾
- level:层数最大的那个节点的层数
- length:跳表的长度
实现部分有以下几点说明:
- 表头:是链表的哨兵节点,不记录主体数据。
- 是个双向链表
- 分值是有顺序的
- o1、o2、o3是节点所保存的成员,是一个指针,可以指向一个SDS值。
- 层级高度最高是32。没每次创建一个新的节点的时候,程序都会随机生成一个介于1和32之间的值作为level数组的大小,这个大小就是“高度”
1.Redis中的redisObject对象
redis中并没有直接使用以上所说的各种数据结构来实现键值数据库,而是基于一种对象,对象底层再间接的引用上文所说的具体的数据结构。
结构如下图:
*ptr属性指向了对象的底层数据结构,而这些数据结构由encoding属性决定。
编码常量 | 编码对应的底层数据结构 |
---|---|
REDIS_ENCODING_INT | long类型的整数 |
REDIS_ENCODING_EMBSTR | emstr编码的简单动态字符串 |
REDIS_ENCODING_RAW | 简单动态字符串 |
REDIS_ENCODING_HT | 字典 |
REDIS_ENCODING_LINKEDLIST | 双端链表 |
REDIS_ENCODING_ZIPLIST | 压缩列表 |
REDIS_ENCODING_INTSET | 整数集合 |
REDIS_ENCODING_SKIPLIST | 跳跃表和字典 |
之所以由encoding属性来决定对象的底层数据结构,是为了实现同一对象类型,支持不同的底层实现。这样就能在不同场景下,使用不同的底层数据结构,进而极大提升Redis的灵活性和效率。
字符串
其中:embstr和raw都是由SDS动态字符串构成的。唯一区别是:raw是分配内存的时候,redisobject和 sds 各分配一块内存,而embstr是redisobject和raw在一块儿内存中。
列表
hash
set
zset
Redis是使用C编写的,内部实现了一个struct结构体redisObject对象,
通过结构体来模仿面向对象编程的“多态”,作为一个底层的数据支持,redisObject代码:
/*
* Redis 对象
*/
typedef struct redisObject {
// 类型
unsigned type:4;
// 对齐位
unsigned notused:2;
// 编码方式
unsigned encoding:4;
// LRU 时间(相对于 server.lruclock)
unsigned lru:22;
// 引用计数
int refcount;
// 指向对象的值
void *ptr;
} robj;
其中type、encoding、ptr3个属性分别表示:
type:redisObject的类型,字符串、列表、集合、有序集、哈希表
encoding:底层实现结构,字符串、整数、跳跃表、压缩列表等
ptr:实际指向保存值的数据结构
如果一个 redisObject 的 type 属性为 REDIS_LIST , encoding 属性为 REDIS_ENCODING_LINKEDLIST ,
那么这个对象就是一个 Redis 列表,它的值保存在一个双端链表内,而 ptr 指针就指向这个双端链表;
如果一个 redisObject 的 type 属性为 REDIS_HASH , encoding 属性为 REDIS_ENCODING_ZIPMAP ,
那么这个对象就是一个 Redis 哈希表,它的值保存在一个 zipmap 里,而 ptr 指针就指向这个 zipmap 。下面这张图片中的REDIS_STRING/REDIS_LIST/REDIS_ZSET/REDIS_HASH/REDIS_SET针对的是redisObject中的type,
后面指向的REDIS_ENCODING_LINKEDLIST等针对的是encoding字段。
Redis的底层数据结构有以下几种:
简单动态字符串sds(Simple Dynamic String)
双端链表(LinkedList)
字典(Map)
跳跃表(SkipList)
下面针对五种数据类型,学习相关的底层数据结构。
2.String
如果一个String类型的value能够保存为整数,则将对应redisObject 对象的encoding修改为REDIS_ENCODING_INT,将对应robj对象的ptr值改为对应的数值。
如果不能转为整数,保持原有encoding为REDIS_ENCODING_RAW。
因此String类型的数据可能使用原始的字符串存储(实际为sds - Simple Dynamic Strings,对应encoding为REDIS_ENCODING_RAW)或者整数存储。
Redis可以直接查看对象的ENCODING值:
redis:6379> set strtest 1
OK
redis:6379> OBJECT ENCODING strtest
"int"
redis:6379> set strtest blog
OK
redis:6379> OBJECT ENCODING strtest
"raw"
3.List
列表的底层实现有2种:
REDIS_ENCODING_ZIPLIST
REDIS_ENCODING_LINKEDLIST
ZIPLIST相比LINKEDLIST可以节省内存,
当创建新的列表时,默认是使用压缩列表作为底层数据结构的。
Redis内部会对相关操作做判断,
当list的elem数小于配置值: hash-max-ziplist-entries 或者elem_value字符串的长度小于 hash-max-ziplist-value, 可以编码成 REDIS_ENCODING_ZIPLIST 类型存储,以节约内存;
但由于在zip list添加和删除元素会涉及到数据移动,
因此当list内容较多时,使用双向链表。
4.Hash
创建新的Hash类型时,默认也使用ziplist存储value,保存数据过多时,使用hast table。
5.Set
集合的底层实现也有两种:
REDIS_ENCODING_INTSET
REDIS_ENCODING_HT(字典)
创建Set类型的key-value时,如果value能够表示为整数,则使用intset类型保存value。
数据量大时,切换为使用hash table保存各个value。
6.Sorted Set
有序集合的底层实现也是2种:
REDIS_ENCODING_ZIPLIST
REDIS_ENCODING_SKIPLIST
为啥 redis 使用跳表(skiplist)而不是使用 red-black?
1、实现简单
2、区间查找,跳表可以做到O(logn) 的时间复杂度定位区间的起点,然后在原始链表中顺序往后遍历就可以了
1.跳表操作时间复杂度和红黑树相同
2.跳表代码实现更易读
3.跳表区间查找效率更高
3.Java 的类加载过程?
类从被加载到虚拟机内存中开始、到卸载出内存为止,整个生命周期包括七个阶段:
-
加载
-
验证
-
准备
-
解析
-
初始化
-
使用
-
卸载
其中,验证、准备、解析这三个部分统称为连接,流程如下图:
前言
一个 Java 文件从编码完成到最终执行,一般主要包括两个过程
- 编译
- 运行
编译,即把我们写好的 Java 文件,通过javac
命令编译成字节码,也就是我们常说的.class
文件。
运行,则是把编译生成的.class
文件交给 Java 虚拟机( JVM )执行。
而我们所说的类加载过程即是指 JVM 虚拟机把.class
文件中类信息加载进内存,并进行解析生成对应的class
对象的过程。
举个通俗点的例子来说,JVM 在执行某段代码时,遇到了class A
, 然而此时内存中并没有class A
的相关信息,于是 JVM 就会到相应的class
文件中去寻找class A
的类信息,并加载进内存中,这就是我们所说的类加载过程。
由此可见,JVM 不是一开始就把所有的类都加载进内存中,而是只有第一次遇到某个需要运行的类时才会加载,且只加载一次。
类加载
类加载的过程主要分为三个部分:
- 加载
- 链接
- 初始化
而链接又可以细分为三个小部分:
- 验证
- 准备
- 解析
class-load
加载
简单来说,加载指的是把class
字节码文件从各个来源通过类加载器装载入内存中。这里有两个重点:
- 字节码来源。一般的加载来源包括从本地路径下编译生成的
.class
文件,从jar
包中的.class
文件,从远程网络,以及动态代理实时编译。 - 类加载器。一般包括启动类加载器,扩展类加载器,应用类加载器以及用户的自定义类加载器。
注:为什么会有自定义类加载器?
- 一方面是由于 Java 代码很容易被反编译,如果需要对自己的代码加密的话,可以对编译后的代码进行加密,然后再通过实现自己的自定义类加载器进行解密,最后再加载。
- 另一方面也有可能从非标准的来源加载代码,比如从网络来源,那就需要自己实现一个类加载器,从指定源进行加载。
链接
验证
主要是为了保证加载进来的字节流符合虚拟机规范,不会造成安全错误。
包括对于文件格式的验证,比如常量中是否有不被支持的常量?文件中是否有不规范的或者附加的其他信息?
对于元数据的验证,比如该类是否继承了被final
修饰的类?类中的字段,方法是否与父类冲突?是否出现了不合理的重载?
对于字节码的验证,保证程序语义的合理性,比如要保证类型转换的合理性。
对于符号引用的验证,比如校验符号引用中通过全限定名是否能够找到对应的类?校验符号引用中的访问性(private
,public
等)是否可被当前类访问?
准备
主要是为类变量(注意,不是实例变量)分配内存,并且赋予初值。
特别需要注意,初值,不是代码中具体写的初始化的值,而是 Java 虚拟机根据不同变量类型的默认初始值。
比如 8 种基本类型的初值,默认为 0;引用类型的初值则为null
;常量的初值即为代码中设置的值,例如final static tmp = 456
, 那么该阶段 456 就是tmp
的初值。
解析
将常量池内的符号引用替换为直接引用的过程。两个重点:
- 符号引用。即一个字符串,但是这个字符串给出了一些能够唯一性识别一个方法,一个变量,一个类的相关信息。
- 直接引用。可以理解为一个内存地址,或者一个偏移量。比如类方法,类变量的直接引用是指向方法区的指针;而实例方法,实例变量的直接引用则是从实例的头指针开始算起到这个实例变量位置的偏移量。
举个例子来说,现在调用方法hello()
,这个方法的地址是1234567
,那么hello
就是符号引用,1234567
就是直接引用。
在解析阶段,虚拟机会把所有的类名,方法名,字段名这些符号引用替换为具体的内存地址或偏移量,也就是直接引用。
初始化
这个阶段主要是对类变量初始化,是执行类构造器的过程。
换句话说,只对static
修饰的变量或语句进行初始化。
如果初始化一个类的时候,其父类尚未初始化,则优先初始化其父类。
如果同时包含多个静态变量和静态代码块,则按照自上而下的顺序依次执行。
总结
类加载过程只是一个类生命周期的一部分,在其前,有编译的过程,只有对源代码编译之后,才能获得能够被虚拟机加载的字节码文件;在其后,还有具体的类使用过程,当使用完成之后,还会在方法区垃圾回收的过程中进行卸载。如果想要了解 Java 类整个生命周期的话,可以自行上网查阅相关资料,这里不再多做赘述。
5.分布式锁的实现
但是应用分布式了之后系统由以前的单进程多线程的程序变为了多进程多线程,这时使用以上的解决方案明显就不够了。
因此业界常用的解决方案通常是借助于一个第三方组件并利用它自身的排他性来达到多进程的互斥。如:
- 基于 DB 的唯一索引。
- 基于 ZK 的临时有序节点。
- 基于 Redis 的
NX EX
参数。
这里主要基于 Redis 进行讨论。
实现
既然是选用了 Redis,那么它就得具有排他性才行。同时它最好也有锁的一些基本特性:
- 高性能(加、解锁时高性能)
- 可以使用阻塞锁与非阻塞锁。
- 不能出现死锁。
- 可用性(不能出现节点 down 掉后加锁失败)。
这里利用 Redis set key
时的一个 NX 参数可以保证在这个 key 不存在的情况下写入成功。并且再加上 EX 参数可以让该 key 在超时之后自动删除。
所以利用以上两个特性可以保证在同一时刻只会有一个进程获得锁,并且不会出现死锁(最坏的情况就是超时自动删除 key)。
加锁
实现代码如下:
private static final String SET_IF_NOT_EXIST = "NX";
private static final String SET_WITH_EXPIRE_TIME = "PX";
public boolean tryLock(String key, String request) {
String result = this.jedis.set(LOCK_PREFIX + key, request, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, 10 * TIME);
if (LOCK_MSG.equals(result)){
return true ;
}else {
return false ;
}
}
注意这里使用的 jedis 的
String set(String key, String value, String nxxx, String expx, long time);
api。
该命令可以保证 NX EX 的原子性。
一定不要把两个命令(NX EX)分开执行,如果在 NX 之后程序出现问题就有可能产生死锁。
阻塞锁
同时也可以实现一个阻塞锁:
//一直阻塞
public void lock(String key, String request) throws InterruptedException {
for (;;){
String result = this.jedis.set(LOCK_PREFIX + key, request, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, 10 * TIME);
if (LOCK_MSG.equals(result)){
break ;
}
//防止一直消耗 CPU
Thread.sleep(DEFAULT_SLEEP_TIME) ;
}
}
//自定义阻塞时间
public boolean lock(String key, String request,int blockTime) throws InterruptedException {
while (blockTime >= 0){
String result = this.jedis.set(LOCK_PREFIX + key, request, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, 10 * TIME);
if (LOCK_MSG.equals(result)){
return true ;
}
blockTime -= DEFAULT_SLEEP_TIME ;
Thread.sleep(DEFAULT_SLEEP_TIME) ;
}
return false ;
}
解锁
解锁也很简单,其实就是把这个 key 删掉就万事大吉了,比如使用 del key
命令。
但现实往往没有那么 easy。
如果线程 A 成功获取到了锁,并且设置了过期时间 30 秒,但线程 A 执行时间超过了 30 秒,锁过期自动释放,此时线程 B 获取到了锁;随后 A 执行完成,线程 A 使用 DEL 命令来释放锁,但此时线程 B 加的锁还没有执行完成,线程 A 实际释放的线程 B 加的锁。
所以最好的方式是在每次解锁时都需要判断锁是否是自己的。
这时就需要结合加锁机制一起实现了。
加锁时需要传递一个参数,将该参数作为这个 key 的 value,这样每次解锁时判断 value 是否相等即可。
所以解锁代码就不能是简单的 del
了。
- 一定要用SET key value NX PX milliseconds 命令
如果不用,先设置了值,再设置过期时间,这个不是原子性操作,有可能在设置过期时间之前宕机,会造成死锁(key永久存在)
- value要具有唯一性
这个是为了在解锁的时候,需要验证value是和
public boolean unlock(String key,String request){
//lua script
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Object result = null ;
if (jedis instanceof Jedis){
result = ((Jedis)this.jedis).eval(script, Collections.singletonList(LOCK_PREFIX + key), Collections.singletonList(request));
}else if (jedis instanceof JedisCluster){
result = ((JedisCluster)this.jedis).eval(script, Collections.singletonList(LOCK_PREFIX + key), Collections.singletonList(request));
}else {
//throw new RuntimeException("instance is error") ;
return false ;
}
if (UNLOCK_MSG.equals(result)){
return true ;
}else {
return false ;
}
}
// 获取锁
// NX是指如果key不存在就成功,key存在返回false,PX可以指定过期时间
SET anyLock unique\_value NX PX 30000
// 释放锁:通过执行一段lua脚本
// 释放锁涉及到两条指令,这两条指令不是原子性的
// 需要用到redis的lua脚本支持特性,redis执行lua脚本是原子性的
if redis.call("get",KEYS\[1\]) == ARGV\[1\] then
return redis.call("del",KEYS\[1\])
else
return0
end
这里使用了一个 lua
脚本来判断 value 是否相等,相等才执行 del 命令。
使用 lua
也可以保证这里两个操作的原子性。
因此上文提到的四个基本特性也能满足了:
- 使用 Redis 可以保证性能。
- 阻塞锁与非阻塞锁见上文。
- 利用超时机制解决了死锁。
- Redis 支持集群部署提高了可用性。
除了要考虑客户端要怎么实现分布式锁之外,还需要考虑redis的部署问题。
redis有3种部署方式:
- 单机模式
- master-slave + sentinel选举模式
- redis cluster模式
使用redis做分布式锁的缺点在于:如果采用单机部署模式,会存在单点问题,只要redis故障了。加锁就不行了。
采用master-slave模式,加锁的时候只对一个节点加锁,即便通过sentinel做了高可用,但是如果master节点故障了,发生主从切换,此时就会有可能出现锁丢失的问题。
另一种方式:Redisson
此外,实现Redis的分布式锁,除了自己基于redis client原生api来实现之外,还可以使用开源框架:Redission
Redisson是一个企业级的开源Redis Client,也提供了分布式锁的支持。我也非常推荐大家使用,为什么呢?
回想一下上面说的,如果自己写代码来通过redis设置一个值,是通过下面这个命令设置的。
- SET anyLock unique_value NX PX 30000
这里设置的超时时间是30s,假如我超过30s都还没有完成业务逻辑的情况下,key会过期,其他线程有可能会获取到锁。
这样一来的话,第一个线程还没执行完业务逻辑,第二个线程进来了也会出现线程安全问题。所以我们还需要额外的去维护这个过期时间,太麻烦了~
我们来看看redisson是怎么实现的?先感受一下使用redission的爽:
Config config = new Config();
config.useClusterServers()
.addNodeAddress("redis://192.168.31.101:7001")
.addNodeAddress("redis://192.168.31.101:7002")
.addNodeAddress("redis://192.168.31.101:7003")
.addNodeAddress("redis://192.168.31.102:7001")
.addNodeAddress("redis://192.168.31.102:7002")
.addNodeAddress("redis://192.168.31.102:7003");
RedissonClient redisson = Redisson.create(config);
RLock lock = redisson.getLock("anyLock");
lock.lock();
lock.unlock();
就是这么简单,我们只需要通过它的api中的lock和unlock即可完成分布式锁,他帮我们考虑了很多细节:
- redisson所有指令都通过lua脚本执行,redis支持lua脚本原子性执行
- redisson设置一个key的默认过期时间为30s,如果某个客户端持有一个锁超过了30s怎么办?
redisson中有一个
watchdog
的概念,翻译过来就是看门狗,它会在你获取锁之后,每隔10秒帮你把key的超时时间设为30s这样的话,就算一直持有锁也不会出现key过期了,其他线程获取到锁的问题了。
- redisson的“看门狗”逻辑保证了没有死锁发生。
(如果机器宕机了,看门狗也就没了。此时就不会延长key的过期时间,到了30s之后就会自动过期了,其他线程可以获取到锁)
基于zookeeper实现分布式锁
常见的分布式锁实现方案里面,除了使用redis来实现之外,使用zookeeper也可以实现分布式锁。
在介绍zookeeper(下文用zk代替)实现分布式锁的机制之前,先粗略介绍一下zk是什么东西:
Zookeeper是一种提供配置管理、分布式协同以及命名的中心化服务。
zk的模型是这样的:zk包含一系列的节点,叫做znode,就好像文件系统一样每个znode表示一个目录,然后znode有一些特性:
- 有序节点:假如当前有一个父节点为
/lock
,我们可以在这个父节点下面创建子节点;zookeeper提供了一个可选的有序特性,例如我们可以创建子节点“/lock/node-”并且指明有序,那么zookeeper在生成子节点时会根据当前的子节点数量自动添加整数序号
也就是说,如果是第一个创建的子节点,那么生成的子节点为
/lock/node-0000000000
,下一个节点则为/lock/node-0000000001
,依次类推。 - 临时节点:客户端可以建立一个临时节点,在会话结束或者会话超时后,zookeeper会自动删除该节点。
- 事件监听:在读取数据时,我们可以同时对节点设置事件监听,当节点数据或结构变化时,zookeeper会通知客户端。当前zookeeper有如下四种事件:
- 节点创建
- 节点删除
- 节点数据修改
- 子节点变更
基于以上的一些zk的特性,我们很容易得出使用zk实现分布式锁的落地方案:
- 使用zk的临时节点和有序节点,每个线程获取锁就是在zk创建一个临时有序的节点,比如在/lock/目录下。
- 创建节点成功后,获取/lock目录下的所有临时节点,再判断当前线程创建的节点是否是所有的节点的序号最小的节点
- 如果当前线程创建的节点是所有节点序号最小的节点,则认为获取锁成功。
- 如果当前线程创建的节点不是所有节点序号最小的节点,则对节点序号的前一个节点添加一个事件监听。
比如当前线程获取到的节点序号为
/lock/003
,然后所有的节点列表为[/lock/001,/lock/002,/lock/003]
,则对/lock/002
这个节点添加一个事件监听器。
如果锁释放了,会唤醒下一个序号的节点,然后重新执行第3步,判断是否自己的节点序号是最小。
比如/lock/001
释放了,/lock/002
监听到时间,此时节点集合为[/lock/002,/lock/003]
,则/lock/002
为最小序号节点,获取到锁。
整个过程如下:
具体的实现思路就是这样,至于代码怎么写,这里比较复杂就不贴出来了。
使用zookeeper的创建节点node
使用zookeeper创建节点node,如果创建节点成功,表示获取了此分布式锁;如果创建节点失败,表示此分布式锁已经被其他程序占用(多个程序同时创建一个节点node,只有一个能够创建成功)
使用zookeeper的创建临时序列节点
使用zookeeper创建临时序列节点来实现分布式锁,适用于顺序执行的程序,大体思路就是创建临时序列节点,找出最小的序列节点,获取分布式锁,程序执行完成之后此序列节点消失,通过watch来监控节点的变化,从剩下的节点的找到最小的序列节点,获取分布式锁,执行相应处理,依次类推......
两种方案的优缺点比较
学完了两种分布式锁的实现方案之后,本节需要讨论的是redis和zk的实现方案中各自的优缺点。
对于redis的分布式锁而言,它有以下缺点:
- 它获取锁的方式简单粗暴,获取不到锁直接不断尝试获取锁,比较消耗性能。
- 另外来说的话,redis的设计定位决定了它的数据并不是强一致性的,在某些极端情况下,可能会出现问题。锁的模型不够健壮
- 即便使用redlock算法来实现,在某些复杂场景下,也无法保证其实现100%没有问题,关于redlock的讨论可以看How to do distributed locking
- redis分布式锁,其实需要自己不断去尝试获取锁,比较消耗性能。
但是另一方面使用redis实现分布式锁在很多企业中非常常见,而且大部分情况下都不会遇到所谓的“极端复杂场景”
所以使用redis作为分布式锁也不失为一种好的方案,最重要的一点是redis的性能很高,可以支撑高并发的获取、释放锁操作。
对于zk分布式锁而言:
- zookeeper天生设计定位就是分布式协调,强一致性。锁的模型健壮、简单易用、适合做分布式锁。
- 如果获取不到锁,只需要添加一个监听器就可以了,不用一直轮询,性能消耗较小。
但是zk也有其缺点:如果有较多的客户端频繁的申请加锁、释放锁,对于zk集群的压力会比较大。
小结:
综上所述,redis和zookeeper都有其优缺点。我们在做技术选型的时候可以根据这些问题作为参考因素。
建议
通过前面的分析,实现分布式锁的两种常见方案:redis和zookeeper,他们各有千秋。应该如何选型呢?
就个人而言的话,我比较推崇zk实现的锁:
因为redis是有可能存在隐患的,可能会导致数据不对的情况。但是,怎么选用要看具体在公司的场景了。
如果公司里面有zk集群条件,优先选用zk实现,但是如果说公司里面只有redis集群,没有条件搭建zk集群。
那么其实用redis来实现也可以,另外还可能是系统设计者考虑到了系统已经有redis,但是又不希望再次引入一些外部依赖的情况下,可以选用redis。
这个是要系统设计者基于架构的考虑了