面试题引出的知识点整理

120 篇文章 1 订阅
97 篇文章 2 订阅

1、自旋锁&可重复锁&公平锁&共享锁&分段锁你都知道吗?
2、无锁&偏向锁&轻量级锁&重量级锁如何膨胀升级?
3、Lock底层AQS实现与Synchronized底层实现异同?
4、LongAdder的分段CAS优化机制如何设计的?
5、Java多线程内存模型(JMM)底层如何实现的
6、volatile底层的内存屏障是如何实现的
7、如何设计能支撑高并发的分布式锁
8、如何设计支撑秒级百万(TPS)的秒杀系统
9、高并发场景缓存穿透&失效&雪崩如何解决
10、高并发场景热点缓存如何重建
11、亿级用户日活统计如何用redis快速计算
12、redis底层zset跳表是如何设计与实现的
13、类似微信的社交App朋友关注模型如何设计实现
14、如何设计高性能电商推荐系统
15、Rocketmq中的顺序消息、延迟消息怎么做到的?
16、大型电商商品详细页如何用CompletableFuture异步加载实现?
17、如何利用并发中ThreadLocal在高并发下做用户身份鉴别?
18、高并发下如何利用信号量Semaphore做流量限流?

面试题引出的知识点整理

1、自旋锁&可重复锁&公平锁&共享锁&分段锁你都知道吗?

1.1、自旋锁

1.1.1、前言

阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长。

1.1.2、自旋锁

在有些场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复现场的花费可能会让系统得不偿失。

如果机器有多个CPU核心,能够让两个或以上的线程同时并行执行,我们就可以让后面那个请求锁的线程不放弃CPU的执行时间,看看持有锁的线程是否很快就会释放锁。

为了让当前线程“稍等一下”,我们需让当前线程进行自旋,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销。这就是自旋锁。

1.2.3、工作流程

在这里插入图片描述

1.2.4、缺点

自旋锁本身是有缺点的,它不能代替阻塞。自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。

  • 如果锁被占用的时间很短,自旋等待的效果就会非常好;
  • 如果锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源。

所以,自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数没有成功获得锁,就应当挂起线程。(这个次数默认是10次,可以配置)

1.2.5、实现原理

自旋锁的实现原理同样也是CAS,AtomicInteger中调用unsafe进行自增操作的源码中的do-while循环就是一个自旋操作,如果修改数值失败则通过循环来执行自旋,直至修改成功。

public final int getAndAddInt (Object var1, Long var2, int var4) {
	int var5;
	do {
		var5 = this.getIntVolatile(var1, var2);
	} while( !this.compareAndSwapInt(var1, var2, var5, var5 + var4));
	
	return var5;
}

1.2.6、自适应自旋

自适应意味着自旋的时间(次数)不固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。

如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。

如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

1.2、可重复锁

1.2.1、 什么是可重入锁

可重入锁又名递归锁

是指在同一个线程外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提,锁对象得是同一个对象),不会因为之前已经获取过还没释放而阻塞。

Java中ReentrantLock和Synchronized都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。

1.2.2、 可重入这四个字分库解释

在这里插入图片描述

1.2.3、可重入锁种类

1.2.3.1、隐式锁

隐式锁,即synchronized关键字使用的锁,默认是可重入锁

1.2.3.1.1、同步代码块

在这里插入图片描述

1.2.3.1.2、同步方法

在这里插入图片描述

1.2.3.1.3、Synchronized的重入的实现机理

每个锁对象拥有一个锁计数器和一个指向持有该锁的线程的指针。

当执行monitorenter时,如果目标锁对象的计数器为零,那么说明它没有被其他线程所持有,Java虚拟机会将该锁对象的持有线程设置为当前线程,并且将其计数器加1。

在目标锁对象的计数器不为零的情况下,如果锁对象的持有线程是当前线程,那么Java虚拟机可以将其计数器加1,否则需要等待,直至持有线程释放该锁。

当执行monitorexit时,Java虚拟机则需将锁对象的计数器减1。计数器为零代表锁已被释放。

1.2.3.2、显式锁

显式锁,即Lock也有ReentrantLock这样的可重入锁。
在这里插入图片描述
在这里插入图片描述

1.3、公平锁

公平锁:多个线程按照申请锁的顺序去获得锁,线程会直接进入队列去排队,永远都是队列的第一位才能得到锁。

  • 优点:所有的线程都能得到资源,不会饿死在队列中。
  • 缺点:吞吐量会下降很多,队列里面除了第一个线程,其他的线程都会阻塞,cpu唤醒阻塞线程的开销会很大。

非公平锁:多个线程去获取锁的时候,会直接去尝试获取,获取不到,再去进入等待队列,如果能获取到,就直接获取到锁。

  • 优点:可以减少CPU唤醒线程的开销,整体的吞吐效率会高点,CPU也不必取唤醒所有线程,会减少唤起线程的数量。
  • 缺点:你们可能也发现了,这样可能导致队列中间的线程一直获取不到锁或者长时间获取不到锁,导致饿死。

1.4、共享锁

共享锁(Shared Lock),又称S锁、读锁。针对行锁。
当有事务对数据加读锁后,其他事务只能对锁定的数据加读锁,不能加写锁(排他锁),所以其他事务只能读,不能写。

📢主要为了支持并发读的场景,读时不允许写操作。

加锁方式:
select * from T where id=1 lock in share mode;
释放方式:
commit、rollback;

1.5、分段锁

1.5.1、分段锁的设计目的

ConcurrentHashMap 是支持高并发的线程安全的 HashMap。相较于 HashTable 使用 synchronized 方法来保证线程安全,ConcurrentHashMap 采用分段锁的方式,在线程竞争激烈的情况下 ConcurrentHashMap 的效率高很多。

ConcurrentHashMap 中的分段锁称为 Segment,它的内部结构是维护一个 HashEntry 数组,同时 Segment 还继承了 ReentrantLock。

当需要 put 元素的时候,并不是对整个 ConcurrentHashMap 进行加锁,而是先通过 hashcode 来判断它放在哪一个分段中,然后对该分段进行加锁。所以当多线程 put 的时候,只要不是放在同一个分段中,就可以实现并行的插入。分段锁的设计目的就是为了细化锁的粒度,从而提高并发能力。

1.5.2、分段锁的实现

1.5.2.1、ConcurrentHashMap 数据结构模型

ConcurrentHashMap 中维护着一个 Segment 数组,Segment 中有维护着一个 HashEntry 的数组,所以 ConcurrentHashMap 的底层数据结构可以理解为:数组 + 数组 + 链表

public class ConcurrentHashMap<K, V> extends AbstractMap<K, V>
        implements ConcurrentMap<K, V>, Serializable {
    private static final long serialVersionUID = 7249069246763182397L;
 
    // 分段数组,每一段都是一个 hash 表
    final Segment<K,V>[] segments;
}
static final class Segment<K,V> extends ReentrantLock implements Serializable {
    // 每段中的表
    transient volatile HashEntry<K,V>[] table;
}

在这里插入图片描述

1.5.2.2、ConcurrentHashMap 分段锁的实现

ConcurrentHashMap 对整个桶数组进行了分割分段(Segment),每一把锁只锁容器其中一部分数据,多线程操作不同的分段,就不会存在锁竞争,提高并发访问率。

Segment 本身就继承了 ReentrantLock 具备了锁的功能,在每次 put 前都会先尝试 tryLock() 加锁,如果成功则进行元素存储;如果失败,就会调用 Segment 的 scanAndLockForPut() 尝试循环加锁并扫描指定的 key。

下面从 put 的源码中看分段锁的具体实现:

public class ConcurrentHashMap<K, V> extends AbstractMap<K, V>
        implements ConcurrentMap<K, V>, Serializable {
    public V put(K key, V value) {
        Segment<K,V> s;
        // 判断 value 是否为 null ,为 null 则直接抛出空指针异常
        if (value == null)
            throw new NullPointerException();
        int hash = hash(key);
        // 获取分段锁的下标
        int j = (hash >>> segmentShift) & segmentMask;
        // 从 Segment 数组中获取该下标的分段对象
        if ((s = (Segment<K,V>)UNSAFE.getObject          
             (segments, (j << SSHIFT) + SBASE)) == null)
            // 如果分段不存在则创建一个新的分段
            s = ensureSegment(j);
        // 调用 Segement 的 put 方法
        return s.put(key, hash, value, false);
    }
}
static final class Segment<K,V> extends ReentrantLock implements Serializable {
        
    final V put(K key, int hash, V value, boolean onlyIfAbsent) {
        // 获取锁,如果获取成功则创建一个临时节点 node = null,如果获取失败,则调用 scanAndLockForPut 循环获取
        HashEntry<K,V> node = tryLock() ? null :
            scanAndLockForPut(key, hash, value);
        V oldValue;
        try {
            HashEntry<K,V>[] tab = table;
            // 获取在分段中的索引
            int index = (tab.length - 1) & hash;
            // 根据索引获取链表的首节点
            HashEntry<K,V> first = entryAt(tab, index);
            // 遍历链表
            for (HashEntry<K,V> e = first;;) {
                if (e != null) {
                    K k;
                    if ((k = e.key) == key ||
                        (e.hash == hash && key.equals(k))) {
                        oldValue = e.value;
                        if (!onlyIfAbsent) {
                            e.value = value;
                            ++modCount;
                        }
                        break;
                    }
                    e = e.next;
                }
                // 遍历完未找到相同的 key
                else {
                    // 将新节点设置为链表头
                    if (node != null)
                        node.setNext(first);
                    else
                        node = new HashEntry<K,V>(hash, key, value, first);
                    int c = count + 1;
                    // 判断是否需要扩容
                    if (c > threshold && tab.length < MAXIMUM_CAPACITY)
                        // 扩容
                        rehash(node);
                    else
                        // 更新数组
                        setEntryAt(tab, index, node);
                    ++modCount;
                    count = c;
                    oldValue = null;
                    break;
                }
            }
        } finally {
            // 释放锁
            unlock();
        }
        // 如果 key 存在,更新 value,返回旧 value
        return oldValue;
    }
 
    private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
        HashEntry<K,V> first = entryForHash(this, hash);
        HashEntry<K,V> e = first;
        HashEntry<K,V> node = null;
        int retries = -1; 
        // 如果第一次获取锁就成功,则直接返回一个 node = null
        while (!tryLock()) {
            HashEntry<K,V> f; 
            // 遍历一次链表,后面检查到链表被其他线程修改,会重新遍历
            if (retries < 0) {
                if (e == null) {
                    if (node == null) 
                        node = new HashEntry<K,V>(hash, key, value, null);
                    retries = 0;
                }
                else if (key.equals(e.key))
                    retries = 0;
                else
                    e = e.next;
            }
            // 尝试获取的次数大于 64,则加互排锁,并结束循环
            else if (++retries > MAX_SCAN_RETRIES) {
                lock();
                break;
            }
 
            // 尝试的次数为偶数时判断一下,是否链表被其他线程改变,如果修改了,则重新遍历
            else if ((retries & 1) == 0 &&
                        (f = entryForHash(this, hash)) != first) {
                e = first = f; 
                retries = -1;
            }
        }
        return node;
    }
 
    private void scanAndLock(Object key, int hash) {
        // similar to but simpler than scanAndLockForPut
        HashEntry<K,V> first = entryForHash(this, hash);
        HashEntry<K,V> e = first;
        int retries = -1;
        while (!tryLock()) {
            HashEntry<K,V> f;
            if (retries < 0) {
                if (e == null || key.equals(e.key))
                    retries = 0;
                else
                    e = e.next;
            }
            else if (++retries > MAX_SCAN_RETRIES) {
                lock();
                break;
            }
            else if ((retries & 1) == 0 &&
                        (f = entryForHash(this, hash)) != first) {
                e = first = f;
                retries = -1;
            }
        }
    }
}

在这里插入图片描述

1.5.3、JDK1.8中的ConcurrentHashMap

jdk1.8 中的 ConcurrentHashMap 中废弃了 Segment 锁,直接使用了数组元素,数组中的每个元素都可以作为一个锁。在元素中没有值的情况下,可以直接通过 CAS 操作来设值,同时保证并发安全;如果元素里面已经存在值的话,那么就使用 synchronized 关键字对元素加锁,再进行之后的 hash 冲突处理。

jdk1.8 的 ConcurrentHashMap 加锁粒度比 jdk1.7 里的 Segment 来加锁粒度更细,并发性能更好。

2、无锁&偏向锁&轻量级锁&重量级锁如何膨胀升级?

2.1、锁的升级膨胀过程

首先我们要明确的是加锁的目的:加锁是为了序列化(也就是按顺序)访问临界资源(临界资源就是一个或多个线程想要竞争并修改的对象),即同一时刻只能有一个线程去访问临界资源(此过程称为同步互斥访问)

2.2、synchronized的使用与原理

2.2.1、加锁方式

  • 同步实例方法,锁是当前实例对象
  • 同步类方法,锁是当前类对象
  • 同步代码块,锁是括号里面的对象

2.2.2、底层原理:

synchronized属于JVM内置锁,也是隐式锁(隐式锁即不需要手动去加锁和解锁,JVM会自动替我们进行加锁和解锁),当然也有显式锁(ReentranLock),回到synchronized的底层原理,synchronized是通过内部对象Monitor(监视器锁)实现的,(这里注意下,每个对象都有与之对应的Monitor)那么是如何使用的呢?
synchronized关键字被编译成字节码后会被翻译成monitorenter和monitorexit两条指令,分别在同步块逻辑代码的与结束位置。
监视器锁的底层实现依赖底层操作系统的fMutex lock(互斥锁)实现,它是一个重量级锁,所以性能比较低。
在这里插入图片描述
锁升级的大致流程:无锁->偏向锁->轻量级锁->重量级锁(锁升级的过程不可逆)

那么问题来了,我们都知道synchronized加锁是加在对象上的,那对象是如何记录锁的状态呢?
答: 锁状态是被记录在每个对象的对象头(Mark Word)中。

2.2.3、对象的内存布局

HotSpot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头,实例数据,对齐填充。

  • 对象头(Header): 由Mark Word 和 元数据指针,数组长度 组成。
    Mark Word: 由hash码,对象所属的年代(GC的时候会用到),对象锁,锁状态标志,偏向锁ID,偏向时间,数组长度等
  • 实例数据(Instance Data) : 即创建对象时对象中成员变量,方法等
  • 对齐填充(Padding): 对象的大小必须是8字节的整数倍!
    在这里插入图片描述

对象头:
HotSpot虚拟机的对象头包括两部分信息。第一部分是"Mark Word",用于存储对象自身的运行时数据,如哈希码(HashCode),GC分代年龄,锁状态标志,线程持有的锁,偏向锁ID,偏向时间戳等等。

这部分数据的长度在32位和64位的虚拟机(暂不考虑开启压缩指针的场景)中分别为32个和64个Bits,官方称它为“Mark Word”。对象需要存储的运行时数据很多,其实已经超出了32、64位Bitmap结构所能记录的限度,但是对象头信息是与对象自身定义的数据无关的额 外存储成本,考虑到虚拟机的空间效率,Mark Word被设计成一个非固 定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用 自己的存储空间。例如在32位的HotSpot虚拟机 中对象未被锁定的状态下,Mark Word的32个Bits空间中的25Bits用于存储对象哈希码(HashCode),4Bits用于 存储对象分代年龄,2Bits用于存储锁标志 位,1Bit固定为0,在其他状态(轻 量级锁定、重量级锁定、GC标记、可偏向)下对象的存储内容如下表所示

在这里插入图片描述
但是如果对象是数组类型,则需要三个机器码,因为JVM虚拟机可以通过 Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。

2.3、偏向锁

偏向锁是Java 6之后加入的新锁,它是一种针对加锁操作的优化手段,经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作)的代价而引入偏向锁。偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。下面我们接着了解轻量级锁。

2.4、轻量级锁

倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的),此时Mark Word 的结构也变为轻量级锁的结构。轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁

2.5、自旋锁

轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),一般不会太久,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。 最后没办法也就只能升级为重量级锁了。

2.6、锁消除

消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间,如下StringBuffer的append是一个同步方法,但是在add方法中的StringBuffer属于一个局部变量, 并且不会被其他线程所使用,因此StringBuffer不可能存在共享资源竞争的情景,JVM会自动将其锁消除。
在这里插入图片描述

2.7、锁升级的具体过程

  • 当只有线程1访问同步块的时候,会去检查对象Object的对象头中的标志位01和是否偏向01,(当线程1访问之前,Object是出于无锁的状态,所以Object是无偏向的) 那么线程1通过CAS修改Mark Work获取偏向锁,此时由无锁升级为偏向锁。此时Objected的Mark Word 中的线程ID已经指向线程1了
    在这里插入图片描述

  • 这时线程2也来了,也想要访问同步代码块,那么线程2就会去检查Object的Mark Word ,看偏向ID是否是自己,发现并不是指向自己的,那么就会CAS去修改Mark Word 去尝试修改线程ID为自己(同时我们需要注意的是偏向锁是不会自动释放的),由于线程1一直持有者锁在执行,所以线程2就尝试修改失败(注意此时已经两个线程了),那么线程2就会去找JVM去撤销线程1的偏向锁。

  • 同时,如果线程1到达安全点后刚好完成任务(当线程执行到达安全点后才能被中断,不可以随意中断),那么线程1就会释放锁,将TheadID置空,同时偏向由1改为0,线程2通过CAS拿到Object,如果没有执行完,就会将锁升级为轻量级锁。

  • 同时,线程1线程2都会在各自的线程栈中创建Lock Record区域,复制对象头到Lock Record区域,并在此区域存入owner指针,此时升级成轻量级锁的对象中的前30位存的是指向Lock Record 的指针。线程1和线程2就开始竞争锁,都用CAS去修改Mark Word的前三十位。

如果成功修改到Mark Work前三十位指向自己线程1的Lock Record,那么线程2就失败了,又开始自旋。线程1执行完之后释放锁,线程2就可以去修改Mark Work前三十位指向自己线程2的Lock Record。

关于轻量级锁的加锁的简洁版:锁升级为轻量级锁之后,对象的 Markword 也会进行相应的变化。升级为轻量级锁的过程:

  • 线程在自己的栈桢中创建锁记录 LockRecord。
  • 将锁对象的对象头中的MarkWord复制到线程的刚刚创建的锁记录中。
  • 将锁记录中的 Owner 指针指向锁对象。
  • 将锁对象的对象头的 MarkWord替换为指向锁记录的指针

在这里插入图片描述

在这里插入图片描述

  • 如果线程2自旋一定次数后线程1还没有释放,(以Liunux为例) 线程2会让虚拟机去调用PThead 用户态切换为内核态,去把原本指向轻量级锁的指针修改为指向重量级锁Monitor的指针,同时把线程2阻塞挂起。当线程1执行完之后释放锁的时候,发现前三十位已经指向重量级锁了,那么在释放轻量级锁并唤醒被阻塞的线程,进行新一轮的锁竞争。

monitor 依赖操作系统的 MutexLock(互斥锁)来实现的, 线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

3、Lock底层AQS实现与Synchronized底层实现异同?

3.1、Lock底层AQS实现

基于ReentrantLock来讲解,ReentrantLock加锁只是对AQS的api的调用,底层的锁的状态(state)和其他线程等待(Node双向链表)的过程其实是由AQS来维护的
加锁

先来看看加锁的过程,先看源码,然后模拟两个线程来加锁的过程。
在这里插入图片描述
上图是ReentrantLock的部分实现。里面有一个Sync的内部类的实例变量,这个Sync内部类继承自AQS,Sync子类就包括公平锁和非公平锁的实现。说白了其实ReentrantLock是通过Sync的子类来实现加锁。

来看一下Sync的非公平锁的实现NonfairSync。
在这里插入图片描述
重写了它的lock加锁方法,在实现中因为是非公平的,所以一进来会先通过cas尝试将AQS类的state参数改为1,直接尝试加锁。如果尝试加锁失败会调用AQS的acquire方法继续尝试加锁。

假设这里有个线程1先来调用lock方法,那么此时没有人加锁,那么就通过CAS操作,将AQS中的state中的变量由0改为1,代表有人来加锁,然后将加锁的线程设置为自己如图。
在这里插入图片描述
那么此时有另一个线程2来加锁,发现通过CAS操作会失败,因为state已经被设置为1了,线程线程2就会设置失败,那么此时就会走else,调用AQS的acquire方法继续尝试加锁。

在这里插入图片描述
进入到acquire会先调用tryAcquire再次尝试加锁,而这个tryAcquire方法AQS其实是没有什么实现的,会调用到NonfairSync里面的tryAcquire,而tryAcquire实际会调用到Sync内部类里面的nonfairTryAcquire非公平尝试加锁方法。
在这里插入图片描述
在这里插入图片描述
先获取锁的状态,判断锁的状态是不是等于0,等于0说明没人加锁,可以尝试去加,如果被加锁了,就会走else if,else if会判断加锁的线程是不是当前线程,是的话就给state 加 1,代表当前线程加了2次锁,就是可重入锁的意思(所谓的可重入就是代表一个线程可以多次获取到锁,只是将state 设置为多次,当线程多次释放锁之后,将state 设置为0才代表当前线程完全释放了锁)。

这里所有的条件假设都不成立。也就是线程2尝试加锁的时候,线程1并没有释放锁,那么这个方法就会返回false。

接下来就会走到addWaiter方法,这个方法很重要,就是将当前线程封装成一个Node,然后将这个Node放入双向链表中。addWaiter先根据指定模式创建指定的node节点,因为ReentrantLock是独占模式,所以传进去的EXCLUSIVE,这里通过当前线程和模式传入,初始化一个双向node节点,获取最后一个节点,根据最后一个节点是否存在来操作当前节点的父级。如果尾节点不存在会去调用enq去初始化
在这里插入图片描述
放入链表中之后如图。
在这里插入图片描述
然后调用acquireQueued方法
在这里插入图片描述
这个方法一进来也会尝试将当前节点去加锁,然后如果加锁成功就将当前节点设置为头节点,最后将当前线程中断,等待唤醒。

线程2进来的时候,刚好线程2的前一个节点是头节点,但是不巧的是调用tryAcquire方法,还是失败,那么此时就会走shouldParkAfterFailedAcquire方法,这个方法是在线程休眠之前调用的,很重要,我们来看看干了什么事。
在这里插入图片描述
判断当前节点的父级节点的状态,如果父级状态是-1,则代表当前线程可以被唤醒了。如果父级的状态为非取消状态(什么叫非取消状态,就是tryLock方法等待了一些时间没获取到锁的线程就处于非取消状态)就跳过父级,寻找下一个可以被唤醒的父级,然后绑定上节点关系,最后将父级的状态更改为-1。也就说,线程(Node)加入队列之后,如果没有获取到锁,在睡眠之前,会将当前节点的前一个节点设置为非取消状态的节点,然后将前一个节点的waitStatus设置为-1,代表前一个节点在释放锁的时候需要唤醒下一个节点。这一步骤主要是防止当前休眠的线程无法被唤醒。这一切设置成功之后,就会返回true。

接下来就会调用parkAndCheckInterrupt
在这里插入图片描述
这个方法内部调用LockSupport.park方法,此时当前线程就会休眠。

到这一步线程2由于没有获取到锁,就会在这里休眠等待被唤醒。

来总结一下加锁的过程。

线程1先过来,发现没人加锁,那么此时就会加上锁。此时线程2过来,在线程2加锁的过程中,线程1始终没有释放锁,那么线程2就不会加锁成功(如果在线程2加锁的过程中线程1始终释放锁,那么线程2就会加锁成功),线程2没有加锁成功,就会将自己当前线程加入等待队列中(如果没有队列就先初始化一个),然后设置前一个节点的状态,最后通过LockSupport.park方法,将自己这个线程休眠。

如果后面还有线程3,线程4等等诸多的先过来,那么这些线程都会按照前面线程2的步骤,将自己插入链表后面再休眠。
释放锁

ok,说完加锁的过程之后,我们来看看释放锁干了什么。

ReentrantLock的unlock其实是调用AQS的release方法,我们直接进入release方法,看看是如何实现的
在这里插入图片描述
进入tryRelease方法,看一下Sync的实现
在这里插入图片描述
其实很简单,就是判断锁的状态,也就是加了几次锁,然后减去释放的,最后判断释放之后,锁的状态是不是0(因为可能线程加了多次锁,所以得判断一下),是的话说明当前这个锁已经释放完了,然后将占有锁的线程设置为null,然后返回true,

然后就会走接下来的代码。

就是判断当前链表头节点是不是需要唤醒队列中的线程。如果有链表的话,头结点的waitStatus肯定不是0,因为线程休眠之前,会将前一个节点的状态设置为-1,上面加锁的过程中有提到过。

接下来就会走unparkSuccessor方法,successor代表继承者的意思,见名知意,这个方法其实就会唤醒当前线程中离头节点最近的没有状态为非取消的线程。然后调用LockSupport.unpark,唤醒等待的线
在这里插入图片描述

然后线程就会从阻塞的那里苏醒过来,继续尝试获取锁。

我再次贴出这段代码。
在这里插入图片描述
获取到锁之后,就将头节点设置成自己。

对应我们的例子,就是线程1释放锁之后,就会唤醒在队列中线程2,先成2获取到锁之后,就会将自己前一个节点(也就是头节点)从链表中移除,将自己设置成头节点。该方法就会跳出死循环。

在这里插入图片描述
到这里,释放锁的过程就讲完了,其实很简单,就是当线程完完全全释放了锁,会唤醒当前链表中的没有取消的,离头结点最近的节点(一般就是链表中的第二个节点),然后被唤醒的节点就会获取到锁,将头节点设置为自己。

总结

AQS其实就是内部维护一个锁的状态变量state和一个双向链表,加锁成功就将state的值加1,加锁失败就将自己当前线程放入链表的尾部,然后休眠,等待其他线程完完全全释放锁之后将自己唤醒,唤醒之后会尝试加锁,加锁成功就会执行业务代码了。

3.2、Synchronized底层实现

synchronized属于JVM内置锁,也是隐式锁(隐式锁即不需要手动去加锁和解锁,JVM会自动替我们进行加锁和解锁),当然也有显式锁(ReentranLock),回到synchronized的底层原理,synchronized是通过内部对象Monitor(监视器锁)实现的,(这里注意下,每个对象都有与之对应的Monitor)那么是如何使用的呢?
synchronized关键字被编译成字节码后会被翻译成monitorenter和monitorexit两条指令,分别在同步块逻辑代码的与结束位置。
监视器锁的底层实现依赖底层操作系统的fMutex lock(互斥锁)实现,它是一个重量级锁,所以性能比较低。
在这里插入图片描述

3.3、Lock和Synchronized的异同

synchronizedlock
内置的Java关键字Java类
无法判断获取锁的状态可以判断是否获取到了锁
会自动释放锁必须要手动释放锁,如果不释放锁会造成死锁
获得锁的线程阻塞时,未获得锁的线程会一直等待不会一直等待
可重入锁可重入锁
不可以中断可以判断锁
非公平锁非公平锁(可以配置)
适合锁少量的代码同步问题适合锁大量的同步代码

4、LongAdder的分段CAS优化机制如何设计的?

4.1、什么是LongAdder

LongAdder是JDK1.8由Doug Lea大神新增的原子操作类,位于java.util.concurrent.atomic包下,LongAdder在高并发的场景下会比AtomicLong 具有更好的性能,代价是消耗更多的内存空间。
在这里插入图片描述

4.2、为什么需要LongAdder

在LongAdder之前,当我们在进行计数统计的时,通常会使用AtomicLong来实现。AtomicLong能保证并发情况下计数的准确性,其内部通过CAS来解决并发安全性的问题。
看一下AtomicLong的增加计数方法源码:
在这里插入图片描述
在这里插入图片描述
可以看到在高并发情况下,当有大量线程同时去更新一个变量,任意一个时间点只有一个线程能够成功,绝大部分的线程在尝试更新失败后,会通过自旋的方式再次进行尝试,这样严重占用了CPU的时间片,进而导致系统性能问题。

4.3、工作原理

LongAdder设计思想上,采用分段的方式降低并发冲突的概率。通过维护一个基准值base和Cell 数组:

  • 当没有出现多线程竞争的情况,线程会直接对base里面的value进行修改。
  • 当多线程的时候,那么LongAdder会初始化一个cell数组,然后对每个线程获取对应的hash值,之后通过hash & (size -1)[size为cell数组的长度]将每个线程定位到对应的cell单元格,之后这个线程将值写入对应的cell单元格中的value,之后将所有cell单元格的value和base中的value进行累加求和得到最终的值。并且每个线程竞争的Cell的下标不是固定的,如果CAS失败,会重新获取新的下标去更新,从而极大地减少了CAS失败的概率。
    在这里插入图片描述

4.4、使用样例

public class LongAdderTestDemo {
	public static void main(String[] args) throws InterruptedException {
		LongAdder adder = new LongAdder();
        int [] num = new int[1];
 
		Thread[] threads = new Thread[10];
	    for (int i = 0; i < 10; i++) {
	        threads[i] = new Thread(() -> {
	            for (int j = 0; j < 10000; j++) {
	                adder.add(1);
	                num[0] += 1;
	            }
	        });
	        threads[i].start();
	    }
	    for (int i = 0; i < 10; i++) {
	        threads[i].join();
	    }
	    // 通过对比发现,使用LongAdder能保证最后的值是期望的值,不存在并发写错误
	    System.out.println("adder:" + adder);
	    System.out.println("num:" + num[0]);
	}
}

在这里插入图片描述

4.5、源码学习

4.5.1、准备工作

LongAdder类继承Striped64类,本身只提供了有一个无参构造方法:
在这里插入图片描述

对外暴露increment、decrement、sum等方法,其中increment和decrement内部调用的也是add方法。
再看父类Striped64都包含什么成员变量(代码太长,不适合截图,直接按需贴代码段):

abstract class Striped64 extends Number {
   
    @jdk.internal.vm.annotation.Contended static final class Cell {
		// 代码省略...
    }
 
    /**
     * 当然CPU的数量,在Cell数组扩容时会被使用到
     */
    static final int NCPU = Runtime.getRuntime().availableProcessors();
 
    /**
	 * cell数组,长度是2的幂次方
     */
    transient volatile Cell[] cells;
 
    /**
     * Base value, used mainly when there is no contention, but also as
     * a fallback during table initialization races. Updated via CAS.
       通常如下两种场景使用:
     * 1.通常在无并发时,线程直接CAS更新该值
       2.初始化数组时,必须要保证数组只被初始化一次,其他竞争失败的线程会将数值累加到base上 
     */
    transient volatile long base;
 
    /**
     *  cell数组对应的锁,初始化cells或者扩容cells需要获取锁。
     *  0表示当前cell数组没有线程使用,1表示当前数组已经有线程占用
     */
    transient volatile int cellsBusy;
 
    // VarHandle 是JDK9之后提供的用于替代之前Unsafe类的,可直接操作内存中的变量 
    private static final VarHandle BASE;
    private static final VarHandle CELLSBUSY;
    private static final VarHandle THREAD_PROBE;
 
	// 其他方法省略...
}

对VarHandler的一点说明
Unsafe 是不建议开发者直接使用的,因为 Unsafe 所操作的并不属于Java标准,会容易带来一些安全性的问题。JDK9 之后,官方推荐使用 java.lang.invoke.Varhandle 来替代 Unsafe 大部分功能,对比 Unsafe ,VarHandle 有着相似的功能,但会更加安全,并且,在并发方面也提高了不少性能。

4.5.2、Striped64 关键代码分析

4.5.2.1、static代码块

代码如下截图:
在这里插入图片描述
静态代码块主要是通过MethodHandles和VarHandle来获取Striped64类的变量base、cellsBusy和Thread类的threadLocalRandomProbe变量。

4.5.2.2、Cell类

代码如下截图:
在这里插入图片描述
Cell是一个静态内部类,且被@sun.misc.Contended修饰(避免伪共享问题),内部包含一个被volatile修饰的value变量,内部静态代码块同样使用MethodHandles实现VALUE变量的初始化。此外提供了一个cas接口,底层通过VarHandler的compareAndSet方法实现value值的CAS更新。

4.5.2.3、longAccumulate方法(核心方法)

看代码前先看下入参:

  • long x : 需要增加的值,一般默认都是1

  • LongBinaryOperator fn : 默认传递的是null

  • wasUncontended:竞争标识,如果是false则代表有竞争。只有cells初始化之后,并且当前线程CAS竞争修改失败,才会是false

    final void longAccumulate(long x, LongBinaryOperator fn,
                              boolean wasUncontended) {
        int h;
        // 通过getProbe方法获取为线程生成一个非0的hash值,底层是通过VarHandle实现
        if ((h = getProbe()) == 0) { 
            /*
              如果当前线程的hash值为0,0同数组长度取余后依旧是0,会固定到数组第一个位置。所以                                    
              这里通过ThreadLocalRandom产生一个随机数,重新计算一个hash值,但认为此次不算是                
              一次竞争,所以将wasUncontended设置为true.
            */
            ThreadLocalRandom.current(); // force initialization
            h = getProbe();
            wasUncontended = true;
        }
        // 扩容意向,当为false表示不扩容
        boolean collide = false;                // True if last slot nonempty
        done: for (;;) {
            Cell[] cs; Cell c; int n; long v;
            // 情况一:cells数组不为空,且长度大于0
            if ((cs = cells) != null && (n = cs.length) > 0) {
                if ((c = cs[(n - 1) & h]) == null) {
                    if (cellsBusy == 0) {       // Try to attach new Cell
                        Cell r = new Cell(x);   // Optimistically create
                        if (cellsBusy == 0 && casCellsBusy()) {
                            try {               // Recheck under lock
                                Cell[] rs; int m, j;
                                if ((rs = cells) != null &&
                                    (m = rs.length) > 0 &&
                                    rs[j = (m - 1) & h] == null) {
                                    rs[j] = r;
                                    break done;
                                }
                            } finally {
                                cellsBusy = 0;
                            }
                            continue;           // Slot is now non-empty
                        }
                    }
                    collide = false;
                }
                else if (!wasUncontended)       // CAS already known to fail
                    wasUncontended = true;      // Continue after rehash
                else if (c.cas(v = c.value,
                               (fn == null) ? v + x : fn.applyAsLong(v, x)))
                    break;
                else if (n >= NCPU || cells != cs)
                    collide = false;            // At max size or stale
                else if (!collide)
                    collide = true;
                else if (cellsBusy == 0 && casCellsBusy()) {
                    try {
                        if (cells == cs)        // Expand table unless stale
                            cells = Arrays.copyOf(cs, n << 1);
                    } finally {
                        cellsBusy = 0;
                    }
                    collide = false;
                    continue;                   // Retry with expanded table
                }
                h = advanceProbe(h);
            }
            // 情况二:cells数组还未初始化,最后调用casCellsBusy()通过CAS操作去获取锁
            else if (cellsBusy == 0 && cells == cs && casCellsBusy()) {
                try {                           // Initialize table
                    if (cells == cs) {
                        Cell[] rs = new Cell[2];
                        // 建一个新的Cell元素,value是x值,默认为1
                        rs[h & 1] = new Cell(x);
                        cells = rs;
                        break done;
                    }
                } finally {
                    cellsBusy = 0;
                }
            }
            // 情况三:有别的线程正在初始化数组,则尝试累加在base变量上
            else if (casBase(v = base,
                             (fn == null) ? v + x : fn.applyAsLong(v, x)))
                break done;
        }
    }

4.5.3、LongAdder关键代码分析

4.5.3.1、add 方法(核心入口方法)
public void add(long x) {
    /*
    对下面几个变量的说明:
    1)as表示cells数组的引用
    2)b表示获取的base值
    3)v表示期望值
    4)m表示cells数组的长度
    5)a表示当前线程命中的cell单元格
    */
    Cell[] as; long b, v; int m; Cell a;
 
    /*
        如果cells数组不为空,或者当前线程竞争base失败.
        由于初始时cells为空,第一次调用add()方法的话,(as = cells) != null不成立,            
        所以会执行 !casBase(b = base, b + x)尝试对base值进行CAS修改
    */
    if ((as = cells) != null || !casBase(b = base, b + x)) {
        // 
        boolean uncontended = true;
        if (as == null || (m = as.length - 1) < 0 ||
            (a = as[getProbe() & m]) == null ||
            !(uncontended = a.cas(v = a.value, v + x)))
            longAccumulate(x, null, uncontended);
    }
}

这里着重对内层嵌套的这部分if判断条件进行拆分分析:

if (as == null || (m = as.length - 1) < 0 
	|| (a = as[getProbe() & m]) == null 
	|| !(uncontended = a.cas(v = a.value, v + x)))
		longAccumulate(x, null, uncontended);

这里分为三步:

  • as == null || (m = as.length - 1) < 0
  • (a = as[getProbe() & m]) == null
  • (a = as[getProbe() & m]) == null

其中如果前一个表达式成立,则不会在执行后一个表达式。

第一步:as == null || (m = as.length - 1) < 0

此条件成立说明cells数组还未初始化。如果不成立则说明cells数组已经完成初始化,对应的线程需要找到Cell数组中的元素去写值,接着执行下面步骤2。

第二步:(a = as[getProbe() & m]) == null

通过getProbe()获取当前线程的hash值,并同m做与运算(m=cells长度-1),当条件成立时说明当前线程通过hash计算出来数组位置处的cell为空,则去执行longAccumulate()方法。如果不成立则说明对应的cell不为空,则接着执行下面步骤3。

第三步:!(uncontended = a.cas(v = a.value, v + x)

通过a.cas(v = a.value, v + x)尝试进行一次CAS更改value操作,如果成功则退出if条件,失败则继续往下执行longAccumulate()方法。

4.5.3.2、sum方法
/**
 * 返回累加的和,也就是"当前时刻"的计数值
 * 注意: 高并发时,此返回值可能不是绝对准确的,因为调用这个方法时,没有对Cell数组进行加锁
   可能会有其他线程对Cell中的值进行了修改,也有可能对数组进行了扩容,所以sum返回的值并不是非常精确的,其返回值并不是一个调用sum方法时的原子快照值
 */
public long sum() {
    Cell[] as = cells; Cell a;
    long sum = base;
    if (as != null) {
        for (int i = 0; i < as.length; ++i) {
            if ((a = as[i]) != null)
                sum += a.value;
        }
    }
    return sum;
}

5、Java多线程内存模型(JMM)底层如何实现的

5.1、JVM内存模型与Java内存模型JMM的区别

5.1.1、JVM内存模型(JVM内存区域划分)

众所周知,Java程序如果想要运行那么必须是要建立在JVM的前提下的,Java使用JVM虚拟机屏蔽了像C那样直接与操作系统或者OS接触,让Java语言操作全部建立在JVM的基础之上从而做到了无视平台,一次编译到处运行。
在这里插入图片描述
JVM在运行Java程序时会把自己管理的内存划分为以上区域(运行时数据区),每个区域都有各自的用途以及在Java程序运行时发挥着自己的作用,而其实运行时数据区又会将运行时数据区划分为线程私有区以及线程共享区(GC不会发生在线程私有区),以下为各大区域具体作用:
方法区(Method Area): 方法区(在Java8之后方法区的概念更改为元数据空间)属于线程共享的内存区域,又称Non-Heap(非堆),主要用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,根据Java 虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError 异常。值得注意的是在方法区中存在一个叫运行时常量池(Runtime Constant Pool)的区域,它主要用于存放编译器生成的各种字面量和符号引用,这些内容将在类加载后存放到运行时常量池中,以便后续使用。

JVM堆(Java Heap): Java 堆也是属于线程共享的内存区域,它在虚拟机启动时创建,是Java 虚拟机所管理的内存中最大的一块,主要用于存放对象实例,几乎所有的对象实例都在这里分配内存(并不是所有新建对象new Object()在分配时都会在堆中),注意Java 堆是垃圾收集器管理的主要区域,因此很多时候也被称做GC 堆,如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError 异常。

程序计数器(Program Counter Register): 属于线程私有的数据区域,是一小块内存空间,主要代表当前线程所执行的字节码行号指示器。字节码解释器工作时,通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成,主要作用其实就是因为CPU的时间片在调度线程工作时会发生“中断”某个线程让另外一个线程开始工作,那么当这个“中断”的线程重新被CPU调度时如何得知上次执行到那行代码了?就是通过负责此类的程序计数器来得知。

虚拟机栈(Java Virtual Machine Stacks): 属于线程私有的数据区域,与线程同时创建,总数与线程关联,代表Java方法执行的内存模型。当线程开始执行时,每个方法执行时都会创建一个栈桢来存储方法的的变量表、操作数栈、动态链接方法、返回值、返回地址等信息。每个方法从调用直结束就对于一个栈桢在虚拟机栈中的入栈和出栈过程,如下:
在这里插入图片描述
本地方法栈(Native Method Stacks): 本地方法栈属于线程私有的数据区域,这部分主要与虚拟机用到的C所编写的 Native 方法相关,当有程序需要调用 Native 方法时,JVM会在本地方法栈中维护着一张本地方法登记表,这里只是做登记是哪个线程调用的哪个本地方法接口,并不会在本地方法栈中直接发生调用,因为这里只是做一个调用登记,而真正的调用需要通过本地方法接口去调用本地方法库中C编写的函数,一般情况下,我们无需关心此区域。

JVM内存模型是处于Java的JVM虚拟机层面的,实际上对于操作系统来说,本质上JVM还是存在于主存中,而JMM是Java语言与OS和硬件架构层面的,主要作用是规定硬件架构与Java语言的内存模型,而本质上不存在JMM这个东西,JMM只是一种规范,并不能说是某些技术实现。

5.1.2、Java内存模型JMM概述

Java内存模型(即Java Memory Model,简称JMM)本身是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数据,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程如果想要对一个变量读取赋值等操作那么必须在工作内存中进行,所以线程想操作变量时首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作,操作完成后再将变量刷写回主内存,不能直接操作主内存中的变量,工作内存中存储着主内存中的变量副本拷贝(PS:有些小伙伴可能会疑惑,Java中线程在执行一个方法时就算里面引用或者创建了对象,他不是也存在堆中吗?栈内存储的不仅仅只是对象的引用地址吗?这里简单说一下,当线程真正运行到这一行时会根据局部表中的对象引用地址去找到主存中的真实对象,然后会将对象拷贝到自己的工作内存再操作…,但是当所操作的对象是一个大对象时(1MB+)并不会完全拷贝,而是将自己操作和需要的那部分成员拷贝),前面说过,工作内存是每个线程的私有数据区域,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成,其简要访问过程如下图:
在这里插入图片描述
重点注意!!!JMM与JVM内存区域的划分是不同的概念层次,在理解JMM的时候不要带着JVM的内存模型去理解,更恰当说JMM描述的是一组规则,通过这组规则控制程Java序中各个变量在共享数据区域和私有数据区域的访问方式,JMM是围绕原子性,有序性、可见性拓展延伸的。JMM与Java内存区域唯一相似点,都存在共享数据区域和私有数据区域,在JMM中主内存属于共享数据区域,从某个程度上讲应该包括了堆和方法区,而工作内存数据线程私有数据区域,从某个程度上讲则应该包括程序计数器、虚拟机栈以及本地方法栈。或许在某些地方,我们可能会看见主内存被描述为堆内存,工作内存被称为线程栈,实际上他们表达的都是同一个含义。关于JMM中的主内存和工作内存说明如下:
主内存: 主要存储的是Java实例对象,所有线程创建的实例对象都存放在主内存中(除开开启了逃逸分析和标量替换的栈上分配和TLAB分配),不管该实例对象是成员变量还是方法中的本地变量(也称局部变量),当然也包括了共享的类信息、常量、静态变量。由于是共享数据区域,多条线程对同一个变量进行非原子性操作时可能会发现线程安全问题。
工作内存: 主要存储当前方法的所有本地变量信息(工作内存中存储着主内存中的变量副本拷贝),每个线程只能访问自己的工作内存,即线程中的本地变量对其它线程是不可见的,就算是两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建属于当前线程的本地变量,当然也包括了字节码行号指示器、相关Native方法的信息。注意由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,线程之间的通讯还是需要依赖于主存,因此存储在工作内存的数据不存在线程安全问题。

弄清楚主内存和工作内存后,接了解一下主内存与工作内存的数据存储类型以及操作方式,根据虚拟机规范,对于一个实例对象中的成员方法而言,如果方法中包含本地变量是基本数据类型(boolean,byte,short,char,int,long,float,double),将直接存储在工作内存的帧栈结构中的局部变量表,但倘若本地变量是引用类型,那么该对象的在内存中的具体引用地址将会被存储在工作内存的帧栈结构中的局部变量表,而对象实例将存储在主内存(共享数据区域,堆)中。但对于实例对象的成员变量,不管它是基本数据类型或者包装类型(Integer、Double等)还是引用类型,都会被存储到堆区(栈上分配与TLAB分配除外)。至于static变量以及类本身相关信息将会存储在主内存中。需要注意的是,在主内存中的实例对象可以被多线程共享,倘若两条线程同时调用了同一个类的同一个方法,那么两条线程会将要操作的数据拷贝一份到自己的工作内存中,执行完成操作后才刷新到主内存,简单示意图如下所示:

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

5.2、计算机硬件内存架构、OS与Java多线程实现原理及Java内存模型

5.2.1、计算机硬件内存架构

在这里插入图片描述
正如上图所示,经过简化CPU与内存操作的简易图,实际上没有这么简单,这里为了理解方便,我们省去了南北桥。就目前计算机而言,一般拥有多个CPU并且每个CPU可能存在多个核心,多核是指在一枚处理器(CPU)中集成两个或多个完整的计算引擎(内核),这样就可以支持多任务并行执行,从多线程的调度来说,每个线程都会映射到各个CPU核心中并行运行。在CPU内部有一组CPU寄存器,寄存器是cpu直接访问和处理的数据,是一个临时放数据的空间。一般CPU都会从内存取数据到寄存器,然后进行处理,但由于内存的处理速度远远低于CPU,导致CPU在处理指令时往往花费很多时间在等待内存做准备工作,于是在寄存器和主内存间添加了CPU缓存,CPU缓存比较小,但访问速度比主内存快得多,如果CPU总是操作主内存中的同一址地的数据,很容易影响CPU执行速度,此时CPU缓存就可以把从内存提取的数据暂时保存起来,如果寄存器要取内存中同一位置的数据,直接从缓存中提取,无需直接从主内存取。需要注意的是,寄存器并不每次数据都可以从缓存中取得数据,万一不是同一个内存地址中的数据,那寄存器还必须直接绕过缓存从内存中取数据。所以并不每次都得到缓存中取数据,这种现象有个专业的名称叫做缓存的命中率,从缓存中取就命中,不从缓存中取从内存中取,就没命中,可见缓存命中率的高低也会影响CPU执行性能,这就是CPU、缓存以及主内存间的简要交互过程,总而言之当一个CPU需要访问主存时,会先读取一部分主存数据到CPU缓存(当然如果CPU缓存中存在需要的数据就会直接从缓存获取),进而在读取CPU缓存到寄存器,当CPU需要写数据到主存时,同样会先刷新寄存器中的数据到CPU缓存,然后再把数据刷新到主内存中。实则就类似于Appcalition(Java) --> Cache(Redis) --> DB(MySQL)的关系,Java程序的性能由于DB需要走磁盘受到了影响,导致Java程序在处理请求时需要等到DB的处理结果,而此时负责处理该请求的线程一直处于阻塞等待状态,只有当DB处理结果返回了再继续负责工作,那么实际上整个模型中的问题是:DB的速度跟不上Java程序的性能,导致整个请求处理起来变的很慢,但是实际上在DB处理的过程Java的线程是处于阻塞不工作的状态的,那么实际上是没有必要的,因为这样最终会导致整体系统的吞吐量下降,此时我们可以加入Cache(Redis)来提升程序响应效率,从而整体提升系统吞吐和性能。(实际上我们做性能优化的目的就是让系统的每个层面处理的速度加快,而架构实际上就是设计一套能够吞吐更大量的请求的系统)。

5.2.2、OS与JVM线程关系及Java线程实现原理

在以上的阐述中我们大致了解完了硬件的内存架构和JVM内存模型以及Java内存模型之后,接着了解Java中线程的实现原理,理解线程的实现原理,有助于我们了解Java内存模型与硬件内存架构的关系,在Windows OS和Linux OS上,Java线程的实现是基于一对一的线程模型,所谓的一对一模型,实际上就是通过语言级别层面程序去间接调用系统内核的线程模型,即我们在使用Java线程时,比如:new Thread(Runnable);JVM内部是转而调用当前操作系统的内核线程来完成当前Runnable任务。这里需要了解一个术语,内核线程(Kernel-Level Thread,KLT),它是由操作系统内核(Kernel)支持的线程,这种线程是由操作系统内核来完成线程切换,内核通过操作调度器进而对线程执行调度,并将线程的任务映射到各个处理器上。每个内核线程可以视为内核的一个分身,这也就是操作系统可以同时处理多任务的原因。由于我们编写的多线程程序属于语言层面的,程序一般不会直接去调用内核线程,取而代之的是一种轻量级的进程(Light Weight Process),也是通常意义上的线程,由于每个轻量级进程都会映射到一个内核线程,因此我们可以通过轻量级进程调用内核线程,进而由操作系统内核将任务映射到各个处理器,这种轻量级进程与内核线程间1对1的关系就称为Java程序中的线程与OS的一对一模型。如下图:

在这里插入图片描述
Java程序中的每个线程都会经过OS被映射到CPU中进行处理,当然,如果CPU存在多核,那么一个CPU同时也能并行调度执行多个线程。

5.2.3、JMM与硬件内存架构的关系

通过对前面的JVM内存模型、Java内存模型JMM、硬件内存架构以及Java多线程的实现原理,我们可以发现,多线程的执行最终都会映射到硬件处理器上进行执行,但Java内存模型和硬件内存架构并不完全一致。对于硬件内存来说只有寄存器、缓存内存、主内存的概念,并没有工作内存(线程私有数据区域)和主内存(堆内存)之分,也就是说Java内存模型对内存的划分对硬件内存并没有任何影响,因为JMM只是一种抽象的概念,是一组规则,并不实际存在,不管是工作内存的数据还是主内存的数据,对于计算机硬件来说都会存储在计算机主内存中,当然也有可能存储到CPU缓存或者寄存器中,因此总体上来说,Java内存模型和计算机硬件内存架构是一个相互交叉的关系,是一种抽象概念划分与真实物理硬件的交叉。(注意对于JVM内存区域划分也是同样的道理)

在这里插入图片描述

5.2.4、为什么需要有JMM的存在?

接着来谈谈Java内存模型存在的必要性,因为我们去学习某个知识的话要做知其然知其所以然。由于线程是OS的最小操作单位,那么所有的程序运行时的实体本质上都是是一条条线程,Java程序需要运行在OS上也不例外,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数据,线程如果想要操作主存中的某个变量,那么必须通过工作内存间接完成,主要过程是将变量从主内存拷贝的线程自己的工作内存空间,然后对变量先在工作内存中进行操作,操作完成后再将变量刷写回主内存,如果存在两个线程同时对一个主内存中的实例对象的变量进行操作就有可能诱发线程安全问题。
在这里插入图片描述

如图,主内存中存在一个共享变量int i = 0, 第一种情况(图左):

现在有A和B两条线程分别对该变量i进行操作,A/B线程各自的都会先将主存中的i拷贝到自己的工作内存存储为共享变量副本i,然后再对i进行自增操作,那么假设此时A/B同时将主存中i=0拷贝到自己的工作内存中进行操作,那么其实A在自己工作内存中的i进行自增操作是对B的工作内存的副本i不可见的,那么A做了自增操作之后会将结果1刷写回主存,此时B也做了i++操作,那么实际上B刷写回主存的值也是基于之前从主存中拷贝到自己工作内存的值i=0,那么实际上B刷写回主存的值也是1,但是实际上我是两条线程都对主存中 i 进行了自增操作,理想结果应该是i=2,但是此时的情况结果确实i=1。

第二种情况(图右):

假设现在A线程想要修改 i 的值为2,而B线程却想要读取 i 的值,那么B线程读取到的值是A线程更新后的值2还是更新前的值1呢?答案是不确定,即B线程有可能读取到A线程更新前的值1,也有可能读取到A线程更新后的值2,这是因为工作内存是每个线程私有的数据区域,而线程A修改变量 i 时,首先是将变量从主内存拷贝到A线程的工作内存中,然后对变量进行操作,操作完成后再将变量 i 写回主内,而对于B线程的也是类似的,这样就有可能造成主内存与工作内存间数据存在一致性问题,假如A线程修改完后正在将数据写回主内存,而B线程此时正在读取主内存,即将i=1拷贝到自己的工作内存中,这样B线程读取到的值就是x=1,但如果A线程已将x=2写回主内存后,B线程才开始读取的话,那么此时B线程读取到的就是x=2,但到底是哪种情况先发生呢?这是不确定的。

所以如上两种情况对于程序来说是不应该的,假设把这个变量i换成淘宝双十一的商品库存数,A/B线程换成参加双十一的用户,那么这样会导致的问题就是对于淘宝业务团队来说,可能会导致超卖,重复卖等问题的出现,这会由于因为技术上的问题导致出现业务经济上的损失,尤其是是在类似于淘宝双十一此类的大促活动上此类问题如果不控制恰当,出现问题的风险会成倍增长,其实这也就是所谓的线程安全问题。

5.2.5、Java内存模型JMM围绕的三大特性

5.2.5.1、原子性

原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。比如对于一个静态变量int i = 0,两条线程同时对他赋值,线程A操作为 i = 1,而线程B操作为 i = 2,不管线程如何运行,最终 i 的值要么是1,要么是2,线程A和线程B间的操作是没有干扰的,这就是原子性操作,不可被中断的特点。 有点要注意的是,对于32位系统的来说,long类型数据和double类型数据(对于基本数据类型,byte,short,int,float,boolean,char读写是原子操作),它们的读写并非原子性的,也就是说如果存在两条线程同时对long类型或者double类型的数据进行读写是存在相互干扰的,因为对于32位虚拟机来说,每次原子读写是32位的,而long和double则是64位的存储单元,这样会导致一个线程在写时,操作完前32位的原子操作后,轮到B线程读取时,恰好只读取到了后32位的数据,这样可能会读取到一个既非原值又不是线程修改值的变量,它可能是“半个变量”的数值,即64位数据被两个线程分成了两次读取。但也不必太担心,因为读取到“半个变量”的情况比较少见,至少在目前的商用的虚拟机中,几乎都把64位的数据的读写操作作为原子操作来执行,因此对于这个问题不必太在意,知道这么回事即可。 那么其实本质上原子性操作指的就是一组大操作要么就全部执行成功,要么就全部失败,举个例子:下单:{增加订单,减库存} 那么对于用户来说下单是一个操作,那么系统就必须保证下单操作的原子性,要么就增加订单和减库存全部成功,不存在增加订单成功,减库存失败,那么这个例子从宏观上来就就是一个原子性操作,非原子性操作反之,线程安全问题产生的根本原因也是由于多线程情况下对一个共享资源进行非原子性操作导致的。 但是有个点在我们深入研究Java的并发编程以及在研究可见性之前时需要注意的,就是计算机在程序执行的时候对它的优化操作 – 指令重排。计算机在执行程序时,为了提高性能,编译器和处理器的常常会对指令做重排,一般分以下3种:

  • 编译器优化的重排: 编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

  • 指令并行的重排: 现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序。

  • 内存系统的重排: 由于处理器使用缓存和读写缓存冲区,这使得加载(load)和存储(store)操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。 其中编译器优化的重排属于编译期重排,指令并行的重排和内存系统的重排属于处理器重排,在多线程环境中,这些重排优化可能会导致程序出现内存可见性问题,下面分别阐明这两种重排优化可能带来的问题。

5.2.5.1.1、编译器优化指令重排
int a = 0;
int b = 0;

//线程A 线程B
代码1int x = a; 代码3int y = b;
代码2:b = 1; 代码4:a = 2;

此时有4行代码1、2、3、4,其中1、2属于线程A,其中3、4属于线程B,两个线程同时执行,从程序的执行上来看由于并行执行的原因最终的结果 x = 0;y=0; 本质上是不会出现 x = 2;y = 1; 这种结果,但是实际上来说这种情况是有概率出现的,因为编译器一般会对一些代码前后不影响、耦合度为0的代码行进行编译器优化的指令重排,假设此时编译器对这段代码指令重排优化之后,可能会出现如下情况:

//线程A 线程B
代码2:b = 1; 代码4:a = 2;
代码1int x = a; 代码3int y = b;

这种情况下再结合之前的线程安全问题一起理解,那么就可能出现 x = 2;y = 1; 这种结果,这也就说明在多线程环境下,由于编译器会对代码做指令重排的优化的操作(因为一般代码都是由上往下执行,指令重排是OS对单线程运行的优化),最终导致在多线程环境下时多个线程使用变量能否保证一致性是无法确定的(PS:编译器重排的基础是代码不存在依赖性时才会进行的,而依赖性可分为两种:数据依赖(int a = 1;int b = a;)和控制依赖(boolean f = ture;if(f){sout(“123”);}))。

5.2.5.1.2、处理器指令重排

先了解一下指令重排的概念,处理器指令重排是对CPU的性能优化,从指令的执行角度来说一条指令可以分为多个步骤完成,如下:

  • 取指:IF
  • 译码和取寄存器操作数:ID
  • 执行或者有效地址计算:EX
  • 存储器访问:MEM
  • 写回:WB

CPU在工作时,需要将上述指令分为多个步骤依次执行(注意硬件不同有可能不一样),由于每一个步会使用到不同的硬件操作,比如取指时会只有PC寄存器和存储器,译码时会执行到指令寄存器组,执行时会执行ALU(算术逻辑单元)、写回时使用到寄存器组。为了提高硬件利用率,CPU指令是按流水线技术来执行的,如下:
在这里插入图片描述
(流水线技术:类似于工厂中的生产流水线,工人们各司其职,做完自己的就往后面传,然后开始一个新的,做完了再往后面传递…而指令执行也是一样的,如果等到一条指令执行完毕之后再开始下一条的执行,就好比工厂的生产流水线,先等到一个产品生产完毕之后再开始下一个,效率非常低下并且浪费人工,这样一条流水线上同时只会有一个工人在做事,其他的看着,只有当这个产品走了最后一个人手上了并且最后一个工人完成了组装之后第一个工人再开始第二个产品的工作)

从图中可以看出当指令1还未执行完成时,第2条指令便利用空闲的硬件开始执行,这样做是有好处的,如果每个步骤花费1ms,那么如果第2条指令需要等待第1条指令执行完成后再执行的话,则需要等待5ms,但如果使用流水线技术的话,指令2只需等待1ms就可以开始执行了,这样就能大大提升CPU的执行性能。虽然流水线技术可以大大提升CPU的性能,但不幸的是一旦出现流水中断,所有硬件设备将会进入一轮停顿期,当再次弥补中断点可能需要几个周期,这样性能损失也会很大,就好比工厂组装手机的流水线,一旦某个零件组装中断,那么该零件往后的工人都有可能进入一轮或者几轮等待组装零件的过程。因此我们需要尽量阻止指令中断的情况,指令重排就是其中一种优化中断的手段,我们通过一个例子来阐明指令重排是如何阻止流水线技术中断的,如下:

i = a + b;
y = c - d;

在这里插入图片描述
上述便是汇编指令的执行过程,在某些指令上存在X的标志,X代表中断的含义,也就是只要有X的地方就会导致指令流水线技术停顿,同时也会影响后续指令的执行,可能需要经过1个或几个指令周期才可能恢复正常,那为什么停顿呢?这是因为部分数据还没准备好,如执行ADD指令时,需要使用到前面指令的数据R1,R2,而此时R2的MEM操作没有完成,即未拷贝到存储器中,这样加法计算就无法进行,必须等到MEM操作完成后才能执行,也就因此而停顿了,其他指令也是类似的情况。前面讲过,停顿会造成CPU性能下降,因此我们应该想办法消除这些停顿,这时就需要使用到指令重排了,如下图,既然ADD指令需要等待,那我们就利用等待的时间做些别的事情,如把LW R4,c 和 LW R5,d 移动到前面执行,毕竟LW R4,c 和 LW R5,d执行并没有数据依赖关系,对他们有数据依赖关系的SUB R6,R5,R4指令在R4,R5加载完成后才执行的,没有影响,过程如下:
在这里插入图片描述
在这里插入图片描述
正如上图所示,所有的停顿都完美消除了,指令流水线也无需中断了,这样CPU的性能也能带来很好的提升,这就是处理器指令重排的作用。关于编译器重排以及指令重排(这两种重排我们后面统一称为指令重排)相关内容已阐述清晰了,我们必须意识到对于单线程而已指令重排几乎不会带来任何影响,比竟重排的前提是保证串行语义执行的一致性,但对于多线程环境而已,指令重排就可能导致严重的程序轮序执行问题,如下:

int a = 0;
boolean f = false;
public void methodA(){
a = 1;
f = true;
}
public void methodB(){
if(f){
int i = a + 1}
}

如上述代码,同时存在线程A和线程B对该实例对象进行操作,其中A线程调用methodA方法,而B线程调用methodB方法,由于指令重排等原因,可能导致程序执行顺序变为如下:

线程A 线程B
methodA: methodB:
代码1:f= true; 代码1:f= true;
代码2:a = 1; 代码2: a = 0 ; //读取到了未更新的a
代码3: i = a + 1;

由于指令重排的原因,线程A的f置为true被提前执行了,而线程A还在执行a=1,此时因为f=true了,所以线程B正好读取f的值为true,直接获取a的值,而此时线程A还在自己的工作内存中对当中拷贝过来的变量副本a进行赋值操作,结果还未刷写到主存,那么此时线程B读取到的a值还是为0,那么拷贝到线程B工作内存的a=0;然后并在自己的工作内存中执行了 i = a + 1操作,而此时线程B因为处理器的指令重排原因读取a是为0的,导致最终 i 结果的值为1,而不是预期的2,这就是多线程环境下,指令重排导致的程序乱序执行的结果。因此,请记住,指令重排只会保证单线程中串行语义的执行的一致性,能够在单线程环境下通过指令重排优化程序,消除CPU停顿,但是并不会关心多线程间的语义一致性。

5.2.5.2、可见性

经过前面的阐述,如果真正理解了指令重排现象之后的小伙伴再来理解可见性容易了,可见性指的是当一个线程修改了某个共享变量的值,其他线程是否能够马上得知这个修改的值。对于串行程序来说,可见性是不存在的,因为我们在任何一个操作中修改了某个变量的值,后续的操作中都能读取这个变量值,并且是修改过的新值。但在多线程环境中可就不一定了,前面我们分析过,由于线程对共享变量的操作都是线程拷贝到各自的工作内存进行操作后才写回到主内存中的,这就可能存在一个线程A修改了共享变量 i 的值,还未写回主内存时,另外一个线程B又对主内存中同一个共享变量 i 进行操作,但此时A线程工作内存中共享变量 i 对线程B来说并不可见,这种工作内存与主内存同步延迟现象就造成了可见性问题,另外指令重排以及编译器优化也可能导致可见性问题,通过前面的分析,我们知道无论是编译器优化还是处理器优化的重排现象,在多线程环境下,确实会导致程序轮序执行的问题,从而也就导致可见性问题。

5.2.5.3、有序性

有序性是指对于单线程的执行代码,我们总是认为代码的执行是按顺序依次执行的,这样的理解如果是放在单线程环境下没有问题,毕竟对于单线程而言确实如此,代码由编码的顺序从上往下执行,就算发生指令重排序,由于所有硬件优化的前提都是必须遵守as-if-serial语义,所以不管怎么排序,都不会且不能影响单线程程序的执行结果,我们将这称之为有序执行。反之,对于多线程环境,则可能出现乱序现象,因为程序编译成机器码指令后可能会出现指令重排现象,重排后的指令与原指令的顺序未必一致。要明白的是,在Java程序中,倘若在本线程内,所有操作都视为有序行为,如果是多线程环境下,一个线程中观察另外一个线程,所有操作都是无序的,前半句指的是单线程内保证串行语义执行的一致性,后半句则指指令重排现象和工作内存与主内存同步延迟现象。

5.2.6、Java中JMM是怎么去解决如上问题的?

在真正的理解了如上所以内容之后,再来看Java为我们提供的解决方案,如原子性问题,除了JVM自身提供的对基本数据类型读写操作的原子性外,对于方法级别或者代码块级别的原子性操作,可以使用synchronized关键字或者Lock锁接口的实现类来保证程序执行的原子性,关于synchronized的详解(能保证三特性不能禁止指令重排),后续我们会讲到。而工作内存与主内存同步延迟现象导致的可见性问题,可以使用加锁或者Volatile关键字解决,它们都可以使一个线程修改后的变量立即对其他线程可见。对于指令重排导致的可见性问题和有序性问题,则可以利用volatile关键字解决,因为volatile的另外一个作用就是禁止重排序优化,关于volatile稍后会进一步分析。除了靠sychronized和volatile关键字(volatile关键字不能保证原子性,只能保证的是禁止指令重排与可见性问题)来保证原子性、可见性以及有序性外,JMM内部还定义一套happens-before 原则来保证多线程环境下两个操作间的原子性、可见性以及有序性。

5.2.7、Java内存模型JMM中的happens-before 原则

5.2.7.1、线程在执行的过程中与内存的交互

不过在了解JMM中的happens-before 原则之前先对于线程执行过程中与内存的交互操作要有一个简单的认知,Java程序在执行的过程中实际上就是OS在调度JVM的“线程”执行,而在执行的过程中是与内存的交互操作,而内存交互操作有8种(虚拟机实现必须保证每一个操作都是原子的,不可在分的,对于double和long类型的变量来说,load、store、read和write操作在某些平台上允许例外):

  • lock(锁定):作用于主内存的变量,把一个变量标识为线程独占状态;

  • unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定;

  • read(读取):作用于主内存变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用;

  • load(载入):作用于工作内存的变量,它把read操作从主存中变量放入工作内存中;

  • use(使用):作用于工作内存中的变量,它把工作内存中的变量传输给执行引擎,每当虚拟机遇到一个需要使用到变量的值,就会使用到这个指令;

  • assign(赋值):作用于工作内存中的变量,它把一个从执行引擎中接受到的值放入工作内存的变量副本中;

  • store(存储):作用于主内存中的变量,它把一个从工作内存中一个变量的值传送到主内存中,以便后续的write使用;

  • write(写入):作用于主内存中的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中

JMM对这八种指令的使用,制定了如下规则:

  • 1)、不允许read和load、store和write操作之一单独出现。即使用了read必须load,使用了store必须write;

  • 2)、不允许线程丢弃他最近的assign操作,即工作变量的数据改变了之后,必须告知主存;

  • 3)、不允许一个线程将没有assign的数据从工作内存同步回主内存;

  • 4)、一个新的变量必须在主内存中诞生,不允许工作内存直接使用一个未被初始化的变量。就是怼变量实施use、store操作之前,必须经过assign和load操作;

  • 5)、一个变量同一时间只有一个线程能对其进行lock。多次lock后,必须执行相同次数的unlock才能解锁;

  • 6)、如果对一个变量进行lock操作,会清空所有工作内存中此变量的值,在执行引擎使用这个变量前,必须重新load或assign操作初始化变量的值;

  • 7)、如果一个变量没有被lock,就不能对其进行unlock操作。也不能unlock一个被其他线程锁住的变量;

  • 8)、对一个变量进行unlock操作之前,必须把此变量同步回主内存; JMM对这八种操作规则和对volatile的一些特殊规则就能确定哪里操作是线程安全,哪些操作是线程不安全的了。但是这些规则实在复杂,很难在实践中直接分析。所以一般我们也不会通过上述规则进行分析。更多的时候,使用JMM中的happens-before 规则来进行分析。

5.2.7.2、JMM中的happens-before 原则

假如在多线程开发过程中我们都需要通过加锁或者volatile来解决这些问题的话那么编写程序的时候会非常麻烦,而且加锁其实本质上是让多线程的并行执行变为了串行执行,这样会大大的影响程序的性能,那么其实真的需要嘛?不需要,因为在JMM中还为我们提供了happens-before 原则来辅助保证程序执行的原子性、可见性以及有序性的问题,它是判断数据是否存在竞争、线程是否安全的依据,happens-before 原则内容如下:

  • 一、程序顺序原则: 即在一个线程内必须保证语义串行性,也就是说按照代码顺序执行。

  • 二、锁规则: 解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前,也就是说,如果对于一个锁解锁后,再加锁,那么加锁的动作必须在解锁动作之后(同一个锁)。

  • 三、volatile规则: volatile变量的写,先发生于读,这保证了volatile变量的可见性,简单的理解就是,volatile变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能够看到该变量的最新值。

  • 四、线程启动规则: 线程的start()方法先于它的每一个动作,即如果线程A在执行线程B的start方法之前修改了共享变量的值,那么当线程B执行start方法时,线程A对共享变量的修改对线程B可见。

  • 五、传递性优先级规则: A先于B ,B先于C 那么A必然先于C。

  • 六、线程终止规则: 线程的所有操作先于线程的终结,Thread.join()方法的作用是等待当前执行的线程终止。假设在线程B终止之前,修改了共享变量,线程A从线程B的join方法成功返回后,线程B对共享变量的修改将对线程A可见。

  • 七、线程中断规则: 对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测线程是否中断。

  • 八、对象终结规则: 对象的构造函数执行,结束先于finalize()方法。 happens-before 原则无需添加任何手段来保证,这是由JMM规定的,Java程序默认遵守如上八条原则,下面我们再通过之前的案例重新认识这八条原则是如何判断线程是否会出现安全问题:

int a = 0;
boolean f = false;
public void methodA(){
a = 1;
f = true;
}
public void methodB(){
if(f){
int i = a + 1}
}

同样的道理,存在两条线程A和B,线程A调用实例对象的methodA()方法,而线程B调用实例对象的methodB()方法,线程A先启动而线程B后启动,那么线程B读取到的i值是多少呢?现在依据8条原则,由于存在两条线程同时调用,因此程序次序原则不合适。methodA()方法和methodB()方法都没有使用同步手段,锁规则也不合适。没有使用volatile关键字,volatile变量原则不适应。线程启动规则、线程终止规则、线程中断规则、对象终结规则、传递性和本次测试案例也不合适。线程A和线程B的启动时间虽然有先后,但线程B执行结果却是不确定,也是说上述代码没有适合8条原则中的任意一条,也没有使用任何同步手段,所以上述的操作是线程不安全的,因此线程B读取的值自然也是不确定的。修复这个问题的方式很简单,要么给methodA()方法和methodB()方法添加同步手段(加锁)或者给共享变量添加volatile关键字修饰,保证该变量在被一个线程修改后总对其他线程可见。

5.3、Volatile关键字

5.3.1、Volatile关键字保证的可见性

Volatile是Java提供的轻量级同步工具,它能保证可见性和做到禁止指令重排做到有序性,但是它不能保证原子性,如果你的程序必须做到原子性的话那么可以考虑使用JUC的原子包下的原子类(后续篇章会讲到)或者加锁的方式来保证,但是我们假设如果使用volatile来修饰共享变量,那么它能够保证的是一个线程对它所修饰的变量进行更改操作后总是能对其他线程可见,如下:

volatile int i = 0;
public void add(){
i++;
}

对于如上代码,我们任何线程调用add()方法之后对 i 进行i++ 操作之后都是对其他线程可见的,但是这段代码不存在线程安全问题吗?存在,为什么?因为 i++ 并不是原子性操作, i++实际上是三个操作的组成,从主存读取值、工作内存中+1操作、操作结果刷写回主存三步操作所组成的,它们三步中其中一条线程在执行任何一步的时候都有可能被打断,那么还是会出现线程安全问题(具体参考上述线程安全问题第一种情况),但是我们要清楚,此时如果有多条线程调用add()方法,那么此时还是会出现线程安全问题,如果想要解决还是需要使用sync或者lock或者原子类来保证,volatile关键字只能禁止指令重排以及可见性。 那么我们再来看一个案例,此类场景可以使用volatile关键字修饰变量达到线程安全的目的,如下:

volatile boolean flag;
public void toTrue(){
flag = true;
}
public void methodA(){
while(!flag){
System.out.println("我是false....false.....false.......");
}
}

由于对于boolean变量flag值的修改属于原子性操作,因此可以通过使用volatile修饰变量flag,使用该变量对其他线程立即可见,从而达到线程安全的目的。那么JMM是如何实现让volatile变量对其他线程立即可见的呢?实际上,当写一个volatile变量时,JMM会把该线程对应的工作内存中的共享变量值刷新到主内存中,当读取一个volatile变量时,JMM会把该线程对应的工作内存置为无效,那么该线程将只能从主内存中重新读取共享变量。volatile变量正是通过这种写-读方式实现对其他线程可见(但其内存语义实现则是通过内存屏障,稍后会说明)。

5.3.2、Volatile关键字怎么做到禁止指令重排序的?

volatile关键字另一个作用就是禁止编译器或者处理器对进行指令重排优化,从而避免多线程环境下程序出现乱序执行的现象,那么volatile是如何实现禁止指令重排优化的。先了解一个概念,内存屏障(Memory Barrier)。 内存屏障,又称内存栅栏,是一个CPU指令,它的作用有两个,一是保证特定操作的执行顺序,二是保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)。由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。Memory Barrier的另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。

Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序,从而让程序按我们预想的流程去执行。 JMM把内存屏障指令分为4类,StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他3个屏障的效果。现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)。

总之,volatile变量正是通过内存屏障实现其在内存中的语义,即可见性和禁止重排优化。案例如下:

public class Singleton{
	private static Singleton singleton;
	private Singleton(){}
	public static Singleton getInstance(){
		if(singleton == null){
			synchronized(Singleton.class){
				if(singleton == null){
					singleton = new Singleton();
				}
			}
		}
	}
}

上述代码一个经典的双重检测的单例模式的代码,这段代码在单线程环境下并没有什么问题,但如果在多线程环境下就可以出现线程安全问题。原因在于某一个线程执行到第一次检测,读取到的singleton不为null时,singleton的引用对象可能没有完成初始化。因为singleton= new Singleton();可以分为以下3步完成(伪代码)

memory = allocate(); //1.分配对象内存空间
singleton(memory); //2.初始化对象
singleton = memory; //3.设置singleton指向刚分配的内存地址,此时singleton != null

由于步骤1和步骤2间可能会重排序,如下:

memory = allocate(); //1.分配对象内存空间
singleton = memory; //3.设置singleton指向刚分配的内存地址,此时singleton != null
singleton(memory); //2.初始化对象

由于步骤2和步骤3不存在数据依赖关系,而且无论重排前还是重排后程序的执行结果在单线程中并没有改变,因此这种重排优化是允许的。但是指令重排只会保证串行语义的执行的一致性(单线程),但并不会关心多线程间的语义一致性。所以当一条线程访问singleton不为null时,由于singleton实例未必已初始化完成,也就造成了线程安全问题。那么该如何解决呢,很简单,我们使用volatile禁止singleton变量被执行指令重排优化即可。

private volatile static Singleton singleton;

6、volatile底层的内存屏障是如何实现的

6.1、volatile是什么

volatile是java语言提供的一个关键字,用来修饰变量的,使用volatile修饰的变量可以保证并发安全的可见性和有序性。

使用方法就是声明变量之前加一个volatile关键字,然后变量的操作就跟我们平常的操作是一样的。
但是添加的volatile的变量,在编译之后JVM会在操作该变量的前后添加一些指令来保证可见性和有序性。

6.2、volatile对可见性保证

使用volatile关键字修饰的共享变量,每次线程使用之前都会重新从主内存中重新读取最新的值;一旦该共享变量的值被修改了,修改它的线程比如立刻将修改后的值强制刷新回主内存。
在这里插入图片描述
(1)首先看一下上面的图,有工作线程A、工作线程B;假如之前工作线程A、B都是用过这个共享变量 i,工作内存中都有变量副本 i = 0

(2)这个时候工作线程A要执行 i++ 操作,按照volatile关键字的特性,每次使用之前必须从主内存重新读取,所以工作线程A重新从主内存读取(执行read、load指令)得到 i = 0没有变化

(3)然后执行use指令将 i = 0 传递给工作线程,执行 i++ 操作,得到 i = 1

(4)然后执行assign指令,将 i = 1的结果赋值给工作线程,按照volatile的特性;一旦共享变量的值被修改了,需要立即强制刷新回主内存。所以在执行assign赋值更新后的之后,立马执行store、write指令将最新的值传递到主内存,并且赋值给主内存的变量。

(5)此时工作线程B需要用到共享变量 i 了,即使工作内存里面有副本,但是每次还是会重新从主内存中读取最新的值,这个时候读取到 i = 1了

6.3、什么是内存屏障?

volatile的可见性和有序性都是通过内存屏障来实现的

内存屏障,本质上也是一种指令,只不过它具有屏障的作用而已

首先内存屏障是一种指令,无论是在JAVA内存模型还是CPU层次,都是有具体的指令对应的,是一种特殊的指令。

然后这种指令具有屏障的作用,所谓屏障,也就是类似关卡,类似栅栏,具有隔离的作用。

按照内存屏障的分类,我理解有两类:

  • 一类是强制读取主内存,强制刷新主内存的内存屏障,叫做Load屏障和Store屏障
  • 另外一类是禁止指令重排序的内存屏障,有四个分别叫做LoadLoad屏障、StoreStore屏障、LoadStore屏障、StoreLoad屏障

6.4、强制读取/刷新主内存的屏障

Load屏障:执行读取数据的时候,强制每次都从主内存读取最新的值。

Store屏障:每次执行修改数据的时候,强制刷新回主内存。

6.4.1、Load屏障

在这里插入图片描述
如图所示:在工作内存的变量名、变量的值之前有一道关卡或者栅栏,导致变量 i 获取不到工作内存中的值,所以每次只好主内存重新加载

6.4.2、Store屏障

在这里插入图片描述
如图所示,每次执行assign指令将数据变更之后,后面都会紧紧跟着一个Store屏障,让你立刻刷新到主内存。

6.5、禁止指令重排序的屏障

6.5.1、LoadLoad屏障

序列:load1指令 LoadLoad屏障 load2指令

作用:在load1指令和load2指令之间加上 LoadLoad屏障,强制先执行load1指令再执行load2指令;load1指令和load2指令不能进行重排序(LoadLoad屏障 前面load指令禁止和屏障后面的load指令进行重排序)。

6.5.2、StoreStore屏障

序列:store1指令 StoreStore屏障 store2指令

作用:在store1指令和store2指令之间加上StoreStore屏障,强制先执行store1指令再执行store2指令;store1指令不能和store2指令进行重排序(StoreStore屏障 前面的store指令禁止和屏障后面的store指令进行重排序)

6.5.3、LoadStore屏障

序列:load1指令 LoadStore屏障 store2指令

作用:在load1指令和store2指令之前加上LoadStore屏障,强制先执行load1指令再执行store2指令;load1指令和store2执行不能重排序(LoadStore屏障 前面的load执行禁止和屏障后面的store指令进行重排序)

6.5.4、StoreLoad屏障

序列:store1指令 StoreLoad屏障 load2指令

作用:在store1指令和load2指令之间加上StoreLoad屏障,强制先执行store1指令再执行load2指令;

store1指令和load2指令执行不能重排序(StoreLoad屏障 前面的Store指令禁止和屏障后面的Store/Load指令进行重排)
在这里插入图片描述
(1)有三个区域分别是区域1、区域2、区域3

(2)区域1和区域2加了 StoreStore屏障,这样区域1和区域2的Store指令就被隔离开来,不能重排了

(3)区域2和区域3加了StoreLoad屏障,这样区域2和区域3的Store指令、Load指令就被隔离开来,不能重排了

(4)就相当于搞了个栅栏,禁止各个区域之间的指令跳来跳去的,否则就会导致乱序执行

6.6、volatile通过内存屏障保证可见性

volatile修饰的变量,在每个读操作(load操作)之前都加上Load屏障,强制从主内存读取最新的数据。每次在assign赋值后面,加上Store屏障,强制将数据刷新到主内存。

以volatile int = 0;线程A、B进行 i++ 的操作为例:
在这里插入图片描述
如图所示:

  • 线程A读取 i 的值遇到Load屏障,需要强制从主存读取得到 i = 0; 然后传递给工作线程执行++操作

  • cpu执行 i++ 操作得到 i = 1,执行assign指令进行赋值;然后遇到Store屏障,需要强制刷新回主内存,此时得到主内存 i = 1

  • 然后线程B执行读取 i 遇到Load屏障,强制从主内存读取,得到最新的值 i = 1,然后传给工作线程执行++操作,得到 i = 2,同样在赋值后遇到Store屏障立即将数据刷新回主内存

6.7、volatile通过内存屏障保证有序性

将initOk用volatile来修饰

// 步骤1
dataSource = initDataSource();
// 步骤2
httpClient = initHttpClient(); 
// 步骤3
initOK = true;

对应到指令可能是这样的:

// 步骤1  对应上面dataSource = initDataSource();
store datasource指令
// 步骤2  对应上面httpClient = initHttpClient();
store http指令
 
StoreStore屏障  (注意:在store initOK前面加了一个StoreStore屏障)
// 步骤3  对应上面initOK = true;
store initOk = true指令
StoreLoad 屏障 (注意:在store initOK后面加了一个StoreLoad屏障)

注意这里:store initOk指令的前面加了一道StoreStore屏障;后面加了一道StoreLoad屏障

在这里插入图片描述
所以通过volatile修饰initOK,加了屏障之后;store initOK = true 这一条指令是不能跳到store dataSource、store http前面去的,所以必须先执行完前面的执行之后,才能执行store initOK = true

也就是通过加了屏障,store initOK = true 指令不能跟前面的store指令进行交换。所以它就自然得等前面的store指令执行完了之后,才执行store initOK = true的对吧? 然后在线程B那一侧看到的initOK = true的时候,发现资源以及初始化好了,自然就不会报错了。

6.8、volatile为什么不能保证原子性

还是以 i++ 的那个例子为例,volatile int i = 0,假如两个线程A、线程B同时对 i 进行 ++ 操作如下:
在这里插入图片描述
上图存在一种情况就是,线程A、线程B如果几乎同时读取 i = 0 到自己的工作内存中。

线程A执行 i++ 结果后将 i = 1 赋值给工作内存;但是这个时候还没来的将最新的结果刷新回主内存的时候,线程B就读取主内存的旧值 i = 0 ,然后执行use指令将 i = 0的值传递给线程B去进行操作了。

即使这个时候线程A立即将 i = 1刷入主内存,那也晚了;线程B已经使用旧值 i = 0进行操作了,像这种情况计算结果就不对了。

6.9、怎样才能保证原子性

如果要保证原子性的话,落到底层实际还是需要进行加锁的,需要保证任意时刻只能有一个线程能执行成功。

如果要保证原子性的话,同一时刻只能有一个线程或者CPU能够执行成功,底层是需要对硬件进行加锁的,只有某个CPU或者线程锁定了,享有独占的权限,那么它的操作才能是不被其它CPU或者线程打断的。

7、如何设计能支撑高并发的分布式锁

7.1、什么是分布式锁?

对于单机多线程,在 Java 中,我们通常使用 ReetrantLock 这类 JDK 自带的 本地锁 来控制本地多个线程对本地共享资源的访问。对于分布式系统,我们通常使用 分布式锁 来控制多个服务对共享资源的访问。

7.1.1 、分布式锁应具备哪些特性?

分布式锁是多服务共享锁, 在分布式的 部署环境下,通过锁机制来让客户端互斥的对共享资源进行访问,应该具备以下特性。
在这里插入图片描述

  • 互斥性: 同一时间,保证共享资源只能被一个客户端的一个线程能访问,具有排他性。

  • 防死锁: 锁在一段时间后,一定会被释放(正常释放或异常释放)。

  • 高可用: 获取锁的机制必须高可用,性能佳。

  • 阻塞锁(可选): 当前资源已被加锁,其他客户端或者线程是阻塞等待,还是立即返回。

  • 可重入(可选): 当前锁的持有者是否能再次进入。

  • 公平性(可选): 加锁的顺序和请求加锁的顺序是一致,还是随机抢锁。

7.1.2、分布式锁可以解决哪些场景的问题?

分布式锁就是用来 解决高并发访问导致数据不一致的问题 ,这里列举几种常见的场景。

  • 多用户修改数据,造成数据不准确: 多个请求对同一条数据同时进行修改,导致数据不准确。比如 “ 下单减库存 ” 、 “ 互联网秒杀 ” 、 “ 抢红包 ” 、 “ 抢票 ” 、 “ 抢优惠券 ” 、 “ 互联网选号 ” 、“ 转账 ” 等。

  • 多次请求,数据重复: 请求结果暂未返回时, 进行多次操作或重试, 产生多个相同的请求,不加锁的情况下成功,会产生很多重复记录。

  • 分布式协调: 分布式环境下,多台机器都可以执行任务,每次只能一台机器执行,也可以用分布式锁来做标记,只有获取到锁的机器可以执行。

7.1.3、分布式锁有哪些实现方式?

关于锁, Java 提供了种类丰富的锁,每种锁因其特性的不同,在适当的场景下能够展现出非常高的效率。

“ 分布式锁 ” 其实是一种解决方案,并非专有组件或者类,实现这一解决方案仍旧需要额外的组件或者中间件来辅助,甚至某些情况下,需要借助数据库级别的方式来实现。
在这里插入图片描述
关于分布式锁的实现方案,在业界流行的有三种:

  • 基于数据库: 借助数据库锁实现,实现简单,性能是最大问题。( 不推荐 )

  • 基于 redis : CAP 模型属于 AP ,无一致性算法,速度快。( 高性能场景推荐 )

  • 基于 Zookeeper : CAP 模型属于 CP ,可靠性高,性能比 Redis 差一些。( 高可靠场景推荐)

另外,还有使用 etcd 、 consul 来实现的。

7.2、Redis分布式锁

7.2.1、单机架构下的数据一致性问题

场景描述:客户端模拟购买商品过程,在Redis中设定库存总数剩100个,多个客户端同时并发购买。

在这里插入图片描述

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @description
* 最简单的情况,没有加任何的考虑,
* 即使是单体应用,并发情况下数据一致性都有问题
* @param: null
* @return:
*/
 
@RestController
public class NoneController {
 
@Autowired
StringRedisTemplate template;
 
@RequestMapping("/buy")
public String index() {
// Redis中存有goods:001号商品,数量为100  相当于是的redis中的get("goods")的操作。
String result = template.opsForValue().get("goods");
// 获取到剩余商品数
int total = result == null ? 0 : Integer.parseInt(result);
if (total > 0) {
               // 剩余商品数大于0 ,则进行扣减
int realTotal = total - 1;
// 将商品数回写数据库  相当于设置新的值的结果
template.opsForValue().set("goods", String.valueOf(realTotal));
System.out.println("购买商品成功,库存还剩:" + realTotal + "件");
return "购买商品成功,库存还剩:" + realTotal + "件";
} else {
System.out.println("购买商品失败");
}
return "购买商品失败";
}
}

使用Jmeter模拟高并发场景,测试结果如下:
在这里插入图片描述
测试结果出现多个用户购买同一商品,发生了数据不一致问题!解决办法:单体应用的情况下,对并发的操作进行加锁操作,保证对数据的操作具有原子性:

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
 
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
 
/**
 * @description 单体应用的情况下,对并发的操作进行加锁操作,保证对数据的操作具有原子性
 * 1. synchronized
 * 2. ReentrantLock
 * 这种情况下,不会产生并发问题
 * @param: null
 * @return:
 */
@RestController
public class ReentrantLockController {
    // 引入的ReentrantLock 锁机制
    Lock lock = new ReentrantLock();
 
    @Autowired
    StringRedisTemplate template;
 
    @RequestMapping("/buy")
    public String index() {
        // 加锁
        lock.lock();
        try {
            // Redis中存有goods:001号商品,数量为100  相当于是的redis中的get("goods")的操作。
            String result = template.opsForValue().get("goods");
            // 获取到剩余商品数
            int total = result == null ? 0 : Integer.parseInt(result);
            if (total > 0) {
                int realTotal = total - 1;
                // 将商品数回写数据库  相当于设置新的值的结果
                template.opsForValue().set("goods", String.valueOf(realTotal));
                System.out.println("购买商品成功,库存还剩:" + realTotal + "件");
                return "购买商品成功,库存还剩:" + realTotal + "件";
            } else {
                System.out.println("购买商品失败");
            }
        } catch (Exception e) {
            //解锁
            lock.unlock();
        } finally {
            //解锁
            lock.unlock();
        }
        return "购买商品失败";
    }
}

7.2.2、分布式数据一致性问题

上面解决了单体应用的数据一致性问题,但如果是分布式架构部署呢,架构如下:提供两个服务,端口分别为8001、8002,连接同一个Redis服务,在服务前面有一台Nginx作为负载均衡。两台服务代码相同,只是端口不同。
在这里插入图片描述
将8001、8002两个服务启动,每个服务依然用ReentrantLock加锁,用Jmeter做并发测试,发现会出现数据一致性问题!
在这里插入图片描述

7.2.3、redis的set命令来实现分布式加锁

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
 
import java.util.UUID;
 
/**
 * @description 面使用redis的set命令来实现加锁
 * 1.SET KEY VALUE [EX seconds] [PX milliseconds] [NX|XX]
 * EX seconds − 设置指定的到期时间(以秒为单位)。
 * PX milliseconds - 设置指定的到期时间(以毫秒为单位)。
 * NX - 仅在键不存在时设置键。
 * XX - 只有在键已存在时才设置。
 * @param: null
 * @return:
 */
@RestController
public class RedisLockControllerV1 {
 
    public static final String REDIS_LOCK = "good_lock";
 
    @Autowired
    StringRedisTemplate template;
 
    @RequestMapping("/buy")
    public String index() {
 
        // 每个人进来先要进行加锁,key值为"good_lock"
        String value = UUID.randomUUID().toString().replace("-", "");
        try {
            Boolean flag = template.opsForValue().setIfAbsent(REDIS_LOCK, value);
            // 加锁失败
            if (!flag) {
                return "抢锁失败!";
            }
            System.out.println(value + " 抢锁成功");
            String result = template.opsForValue().get("goods");
            int total = result == null ? 0 : Integer.parseInt(result);
            if (total > 0) {
                int realTotal = total - 1;
                template.opsForValue().set("goods", String.valueOf(realTotal));
                System.out.println("购买商品成功,库存还剩:" + realTotal + "件");
                return "购买商品成功,库存还剩:" + realTotal + "件";
            } else {
                System.out.println("购买商品失败");
            }
            return "购买商品失败";
        } finally {
            // 如果在抢到锁之后,删除锁之前,发生了异常,锁就无法被释放,所以要在finally处理 template.delete(REDIS_LOCK);
            template.delete(REDIS_LOCK);
        }
    }
}

7.2.4、 Redis设置过期时间

如果程序在运行期间,部署了微服务jar包的机器突然挂了,代码层面根本就没有走到finally代码块,也就是说在宕机前,锁并没有被删除掉,这样的话,就没办法保证解锁,所以,这里需要对这个key加一个过期时间,Redis中设置过期时间有两种方法:

  • template.expire(REDIS_LOCK,10, TimeUnit.SECONDS)
  • template.opsForValue().setIfAbsent(REDIS_LOCK, value,10L,TimeUnit.SECONDS)

第一种方法需要单独的一行代码,且并没有与加锁放在同一步操作,所以不具备原子性,也会出问题, 第二种方法在加锁的同时就进行了设置过期时间,所有没有问题,这里采用这种方式。

// 为key加一个过期时间,其余代码不变
Boolean flag = template.opsForValue().setIfAbsent(REDIS_LOCK,value,10L,TimeUnit.SECONDS);
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
 
import java.util.UUID;
import java.util.concurrent.TimeUnit;
 
/**
 * @description 在第四种情况下,如果在程序运行期间,部署了微服务的jar包的机器突然挂了,代码层面根本就没有走到finally代码块
 * 没办法保证解锁,所以这个key就没有被删除
 * 这里需要对这个key加一个过期时间,设置过期时间有两种方法
 * 1. template.expire(REDIS_LOCK,10, TimeUnit.SECONDS);第一种方法需要单独的一行代码,并没有与加锁放在同一步操作,所以不具备原子性,也会出问题
 * 2. template.opsForValue().setIfAbsent(REDIS_LOCK, value,10L,TimeUnit.SECONDS);第二种方法在加锁的同时就进行了设置过期时间,所有没有问题
 * @return:
 */
@RestController
public class RedisLockControllerV2 {
 
    public static final String REDIS_LOCK = "good_lock";
 
    @Autowired
    StringRedisTemplate template;
 
    @RequestMapping("/buy")
    public String index() {
 
        // 每个人进来先要进行加锁,key值为"good_lock"
        String value = UUID.randomUUID().toString().replace("-", "");
        try {
            // 为key加一个过期时间  10s
            Boolean flag = template.opsForValue().setIfAbsent(REDIS_LOCK, value, 10L, TimeUnit.SECONDS);
            // 加锁失败
            if (!flag) {
                return "抢锁失败!";
            }
            System.out.println(value + " 抢锁成功");
            String result = template.opsForValue().get("goods");
            int total = result == null ? 0 : Integer.parseInt(result);
            if (total > 0) {
                int realTotal = total - 1;
                template.opsForValue().set("goods", String.valueOf(realTotal));
                System.out.println("购买商品成功,库存还剩:" + realTotal + "件");
                return "购买商品成功,库存还剩:" + realTotal + "件";
            } else {
                System.out.println("购买商品失败");
            }
            return "购买商品失败";
        } finally {
            // 如果在抢到锁之后,删除锁之前,发生了异常,锁就无法被释放,所以要在finally处理  template.delete(REDIS_LOCK);
            template.delete(REDIS_LOCK);
        }
    }
}

7.2.5、Redis设置锁的删除

设置了key的过期时间,解决了key无法删除的问题,但问题又来了,上面设置了key的过期时间为10秒,如果业务逻辑比较复杂,需要调用其他微服务, 处理时间需要15秒(模拟场景,别较真),而当10秒钟过去之后,这个key就过期了,其他请求就又可以设置这个key, 此时如果耗时15秒的请求处理完了,回来继续执行程序,就会把别人设置的key给删除了,这是个很严重的问题!所以,谁上的锁,谁才能删除。

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
 
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
 * @description
 * 在第五种情况下,设置了key的过期时间,解决了key无法删除的问题,但问题又来了
 * 我们设置了key的过期时间为10秒,如果我们的业务逻辑比较复杂,需要调用其他微服务,需要15秒
 * 10秒钟过去之后,这个key就过期了,其他请求就又可以设置这个key了
 * 但是如果耗时的请求处理完了,回来继续执行程序,就会把别人设置的key给删除了,这是个很严重的问题
 * 所以,谁上的锁,谁才能删除
 * @return:
 */
@RestController
public class RedislockControllerV3 {
 
    public static final String REDIS_LOCK = "good_lock";
 
    @Autowired
    StringRedisTemplate template;
 
    @RequestMapping("/buy")
    public String index() {
 
        // 每个人进来先要进行加锁,key值为"good_lock"
        String value = UUID.randomUUID().toString().replace("-", "");
        try {
            // 为key加一个过期时间10s
            Boolean flag = template.opsForValue().setIfAbsent(REDIS_LOCK, value, 10L, TimeUnit.SECONDS);
            // 加锁失败
            if (!flag) {
                return "抢锁失败!";
            }
            System.out.println(value + " 抢锁成功");
            String result = template.opsForValue().get("goods");
            int total = result == null ? 0 : Integer.parseInt(result);
            if (total > 0) {
                // 如果在此处需要调用其他微服务,处理时间较长。。。
                int realTotal = total - 1;
                template.opsForValue().set("goods", String.valueOf(realTotal));
                System.out.println("购买商品成功,库存还剩:" + realTotal + "件");
                return "购买商品成功,库存还剩:" + realTotal + "件";
            } else {
                System.out.println("购买商品失败");
            }
            return "购买商品失败";
        } finally {
            // 谁加的锁,谁才能删除
            if (template.opsForValue().get(REDIS_LOCK).equals(value)) {
                template.delete(REDIS_LOCK);
            }
        }
    }
}

7.2.6、Redis中Lua原子操作

规定了谁上的锁,谁才能删除,但finally快的判断和del删除操作不是原子操作,并发的时候也会出问题,并发嘛,就是要保证数据的一致性,保证数据的一致性, 最好要保证对数据的操作具有原子性。在redis中的保证原子操作的是

  • 使用Lua脚本,进行锁的删除
  • 使用Redis事务来实现原子操作
import com.zhuangxiaoyan.springbootredis.utils.RedisUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import redis.clients.jedis.Jedis;
 
import java.util.Collections;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
 
/**
 * @description 在第六种情况下,规定了谁上的锁,谁才能删除
 * 但finally快的判断和del删除操作不是原子操作,并发的时候也会出问题
 * 并发就是要保证数据的一致性,保证数据的一致性,最好要保证对数据的操作具有原子性
 * @param: null
 * @return:
 */
 
@RestController
public class RedisLockControllerV4 {
 
    public static final String REDIS_LOCK = "good_lock";
 
    @Autowired
    StringRedisTemplate template;
 
    /**
     * @description 使用Lua脚本,进行锁的删除
     * @param:
     * @date: 2022/4/9 21:56
     * @return: java.lang.String
     * @author: xjl
     */
    @RequestMapping("/buy")
    public String index() {
 
        // 每个人进来先要进行加锁,key值为"good_lock"
        String value = UUID.randomUUID().toString().replace("-", "");
        try {
            // 为key加一个过期时间
            Boolean flag = template.opsForValue().setIfAbsent(REDIS_LOCK, value, 10L, TimeUnit.SECONDS);
            // 加锁失败
            if (!flag) {
                return "抢锁失败!";
            }
            System.out.println(value + " 抢锁成功");
            String result = template.opsForValue().get("goods");
            int total = result == null ? 0 : Integer.parseInt(result);
            if (total > 0) {
                // 如果在此处需要调用其他微服务,处理时间较长。。。
                int realTotal = total - 1;
                template.opsForValue().set("goods", String.valueOf(realTotal));
                System.out.println("购买商品成功,库存还剩:" + realTotal + "件");
                return "购买商品成功,库存还剩:" + realTotal + "件";
            } else {
                System.out.println("购买商品失败");
            }
            return "购买商品失败,服务端口为8001";
        } finally {
            // 谁加的锁,谁才能删除  使用Lua脚本,进行锁的删除
            Jedis jedis = null;
            try {
                jedis = RedisUtils.getJedis();
                String script = "if redis.call('get',KEYS[1]) == ARGV[1] " +
                        "then " +
                        "return redis.call('del',KEYS[1]) " +
                        "else " +
                        "   return 0 " +
                        "end";
                Object eval = jedis.eval(script, Collections.singletonList(REDIS_LOCK), Collections.singletonList(value));
                if ("1".equals(eval.toString())) {
                    System.out.println("-----del redis lock ok....");
                } else {
                    System.out.println("-----del redis lock error ....");
                }
            } catch (Exception e) {
                System.out.println(e.getMessage());
            } finally {
                if (null != jedis) {
                    jedis.close();
                }
            }
        }
    }
 
    /**
     * @description 使用redis事务
      * @param:
     * @return: java.lang.String
    */
    @RequestMapping("/buy2")
    public String index2() {
 
        // 每个人进来先要进行加锁,key值为"good_lock"
        String value = UUID.randomUUID().toString().replace("-", "");
        try {
            // 为key加一个过期时间
            Boolean flag = template.opsForValue().setIfAbsent(REDIS_LOCK, value, 10L, TimeUnit.SECONDS);
            // 加锁失败
            if (!flag) {
                return "抢锁失败!";
            }
            System.out.println(value + " 抢锁成功");
            String result = template.opsForValue().get("goods");
            int total = result == null ? 0 : Integer.parseInt(result);
            if (total > 0) {
                // 如果在此处需要调用其他微服务,处理时间较长。。。
                int realTotal = total - 1;
                template.opsForValue().set("goods", String.valueOf(realTotal));
                System.out.println("购买商品成功,库存还剩:" + realTotal + "件");
                return "购买商品成功,库存还剩:" + realTotal + "件";
            } else {
                System.out.println("购买商品失败");
            }
            return "购买商品失败,服务端口为8001";
        } finally {
            // 谁加的锁,谁才能删除 ,使用redis事务
            while (true) {
                template.watch(REDIS_LOCK);
                if (template.opsForValue().get(REDIS_LOCK).equalsIgnoreCase(value)) {
                    template.setEnableTransactionSupport(true);
                    template.multi();
                    template.delete(REDIS_LOCK);
                    List<Object> list = template.exec();
                    if (list == null) {
                        continue;
                    }
                }
                template.unwatch();
                break;
            }
        }
    }
}

7.2.7、Redis集群下的分布式锁

规定了谁上的锁,谁才能删除,并且解决了删除操作没有原子性问题。但还没有考虑缓存续命,以及Redis集群部署下,异步复制造成的锁丢失: 主节点没来得及把刚刚set进来这条数据给从节点,就挂了。所以直接上RedLock的Redisson落地实现。

import org.redisson.Redisson;
import org.redisson.api.RLock;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
 
import java.util.UUID;
/**
 * @description
 * 在第六种情况下,规定了谁上的锁,谁才能删除
 * 1. 缓存续命
 * 2. redis异步复制造成的锁丢失:主节点没来得及把刚刚set进来这条数据给从节点,就挂了
 * @param: null
 * @return:
 */
 
@RestController
public class RedisLockControllerV5 {
 
    public static final String REDIS_LOCK = "good_lock";
 
    @Autowired
    StringRedisTemplate template;
 
    @Autowired
    Redisson redisson;
 
    @RequestMapping("/buy")
    public String index() {
        RLock lock = redisson.getLock(REDIS_LOCK);
        lock.lock();
        // 每个人进来先要进行加锁,key值为"good_lock"
        String value = UUID.randomUUID().toString().replace("-", "");
        try {
            String result = template.opsForValue().get("goods");
            int total = result == null ? 0 : Integer.parseInt(result);
            if (total > 0) {
                // 如果在此处需要调用其他微服务,处理时间较长。。。
                int realTotal = total - 1;
                template.opsForValue().set("goods", String.valueOf(realTotal));
                System.out.println("购买商品成功,库存还剩:" + realTotal + "件");
                return "购买商品成功,库存还剩:" + realTotal + "件";
            } else {
                System.out.println("购买商品失败");
            }
            return "购买商品失败";
        } finally {
            // 如果锁依旧在同时还是在被当前线程持有,那就解锁。 如果是其他的线程持有 那就不能释放锁资源
            if (lock.isLocked() && lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

7.3、zookeeper分布式锁

7.3.1、什么是zookeeper(zk)?

zk是一个分布式协调服务,功能包括:配置维护、域名服务、分布式同步、组服务等。

zk的数据结构跟Unix文件系统类似。是一颗树形结构,这里不做详细介绍。
在这里插入图片描述

7.3.2、zookeeper节点介绍

zk的节点称之为znode节点,znode节点分两种类型:

  • 临时节点(Ephemeral):当客户端与服务器断开连接后,临时znode节点就会被自动删除
  • 持久节点(Persistent):当客户端与服务器断开连接后,持久znode节点不会被自动删除

znode节点还有一些特性:

  • 节点有序:在一个父节点下创建子节点,zk提供了一个可选的有序性,创建子节点时会根据当前子节点数量给节点名添加序号。例:/root下创建/java,生成的节点名称则为java0001,/root/java0001。
  • 临时节点:当会话结束或超时,自动删除节点
  • 事件监听:当节点有创建,删除,数据修改,子节点变更的时候,zk会通知客户端的。

7.3.3、zookeeper分布式锁的实现

zookeeper就是通过临时节点和节点有序来实现分布式锁的。

  • 每个获取锁的线程会在zk的某一个目录下创建一个临时有序的节点。
  • 节点创建成功后,判断当前线程创建的节点的序号是否是最小的。
  • 如果序号是最小的,那么获取锁成功。
  • 如果序号不是最小的,则对他序号的前一个节点添加事件监听。如果前一个节点被删了(锁被释放了),那么就会唤醒当前节点,则成功获取到锁。
    在这里插入图片描述

7.4、zookeepe和redisr两者的优缺

7.4.1、zookeeper

优点
  • 不用设置过期时间
  • 事件监听机制,加锁失败后,可以等待锁释放
缺点
  • 性能不如redis
  • 当网络不稳定时,可能会有多个节点同时获取锁问题。例:node1由于网络波动,导致zk将其删除,刚好node2获取到锁,那么此时node1和node2两者都会获取到锁。

7.4.2、Redis

优点
  • 性能上比较好,天然的支持高并发
缺点
  • 获取锁失败后,得轮询的去获取锁
  • 大多数情况下redis无法保证数据强一致性

8、如何设计支撑秒级百万(TPS)的秒杀系统

8.1、设计秒杀系统时应该注意的5个架构原则

说起秒杀,我想你肯定不陌生,这两年,从双十一购物到春节抢红包,再到 12306 抢火车票,“秒杀”的场景处处可见。简单来说,秒杀就是在同一个时刻有大量的请求争抢购买同一个商品并完成交易的过程,用技术的行话来说就是大量的并发读和并发写。不管是哪一门语言,并发都是程序员们最为头疼的部分。

同样,对于一个软件而言也是这样,你可以很快增删改查做出一个秒杀系统,但是要让它支持高并发访问就没那么容易了。比如说,如何让系统面对百万级的请求流量不出故障?如何保证高并发情况下数据的一致性写?完全靠堆服务器来解决吗?这显然不是最好的解决方案。

在我看来,秒杀系统本质上就是一个满足大并发、高性能和高可用的分布式系统。今天,我们就来聊聊,如何在满足一个良好架构的分布式系统基础上,针对秒杀这种业务做到极致的性能改进。

8.1.1、架构原则:4 要 1 不要

如果你是一个架构师,你首先要勾勒出一个轮廓,想一想如何构建一个超大流量并发读写、高性能,以及高可用的系统,这其中有哪些要素需要考虑。我把这些要素总结为“4 要 1 不要”。

8.1.1.1、数据要尽量少

所谓“数据要尽量少”,首先是指用户请求的数据能少就少。请求的数据包括上传给系统的数据和系统返回给用户的数据(通常就是网页)。为啥“数据要尽量少”呢?因为首先这些数据在网络上传输需要时间,其次不管是请求数据还是返回数据都需要服务器做处理,而服务器在写网络时通常都要做压缩和字符编码,这些都非常消耗 CPU,所以减少传输的数据量可以显著减少 CPU 的使用。例如,我们可以简化秒杀页面的大小,去掉不必要的页面装修效果,等等。其次,“数据要尽量少”还要求系统依赖的数据能少就少,包括系统完成某些业务逻辑需要读取和保存的数据,这些数据一般是和后台服务以及数据库打交道的。调用其他服务会涉及数据的序列化和反序列化,而这也是 CPU 的一大杀手,同样也会增加延时。而且,数据库本身也容易成为一个瓶颈,所以和数据库打交道越少越好,数据越简单、越小则越好。

8.1.1.2、请求数要尽量少

用户请求的页面返回后,浏览器渲染这个页面还要包含其他的额外请求,比如说,这个页面依赖的 CSS/JavaScript、图片,以及 Ajax 请求等等都定义为“额外请求”,这些额外请求应该尽量少。因为浏览器每发出一个请求都多少会有一些消耗,例如建立连接要做三次握手,有的时候有页面依赖或者连接数限制,一些请求(例如 JavaScript)还需要串行加载等。另外,如果不同请求的域名不一样的话,还涉及这些域名的 DNS 解析,可能会耗时更久。所以你要记住的是,减少请求数可以显著减少以上这些因素导致的资源消耗。例如,减少请求数最常用的一个实践就是合并 CSS 和 JavaScript 文件,把多个 JavaScript 文件合并成一个文件,在 URL 中用逗号隔开(https://g.xxx.com/tm/xx-b/4.0.94/mods/??module-preview/index.xtpl.js,module-jhs/index.xtpl.js,module-focus/index.xtpl.js)。这种方式在服务端仍然是单个文件各自存放,只是服务端会有一个组件解析这个URL,然后动态把这些文件合并起来一起返回。

8.1.1.3、路径要尽量短

所谓“路径”,就是用户发出请求到返回数据这个过程中,需求经过的中间的节点数。通常,这些节点可以表示为一个系统或者一个新的 Socket 连接(比如代理服务器只是创建一个新的 Socket 连接来转发请求)。每经过一个节点,一般都会产生一个新的 Socket 连接。然而,每增加一个连接都会增加新的不确定性。从概率统计上来说,假如一次请求经过 5 个节点,每个节点的可用性是 99.9% 的话,那么整个请求的可用性是:99.9% 的 5 次方,约等于 99.5%。所以缩短请求路径不仅可以增加可用性,同样可以有效提升性能(减少中间节点可以减少数据的序列化与反序列化),并减少延时(可以减少网络传输耗时)。要缩短访问路径有一种办法,就是多个相互强依赖的应用合并部署在一起,把远程过程调用(RPC)变成 JVM 内部之间的方法调用。在《大型网站技术架构演进与性能优化》一书中,我也有一章介绍了这种技术的详细实现。

8.1.1.4、依赖要尽量少

所谓依赖,指的是要完成一次用户请求必须依赖的系统或者服务,这里的依赖指的是强依赖。举个例子,比如说你要展示秒杀页面,而这个页面必须强依赖商品信息、用户信息,还有其他如优惠券、成交列表等这些对秒杀不是非要不可的信息(弱依赖),这些弱依赖在紧急情况下就可以去掉。要减少依赖,我们可以给系统进行分级,比如 0 级系统、1 级系统、2 级系统、3 级系统,0 级系统如果是最重要的系统,那么 0 级系统强依赖的系统也同样是最重要的系统,以此类推。注意,0 级系统要尽量减少对 1 级系统的强依赖,防止重要的系统被不重要的系统拖垮。例如支付系统是 0 级系统,而优惠券是 1 级系统的话,在极端情况下可以把优惠券给降级,防止支付系统被优惠券这个 1 级系统给拖垮。

8.1.1.5、不要有单点

系统中的单点可以说是系统架构上的一个大忌,因为单点意味着没有备份,风险不可控,我们设计分布式系统最重要的原则就是“消除单点”。那如何避免单点呢?我认为关键点是避免将服务的状态和机器绑定,即把服务无状态化,这样服务就可以在机器中随意移动。如何那把服务的状态和机器解耦呢?这里也有很多实现方式。例如把和机器相关的配置动态化,这些参数可以通过配置中心来动态推送,在服务启动时动态拉取下来,我们在这些配置中心设置一些规则来方便地改变这些映射关系。应用无状态化是有效避免单点的一种方式,但是像存储服务本身很难无状态化,因为数据要存储在磁盘上,本身就要和机器绑定,那么这种场景一般要通过冗余多个备份的方式来解决单点问题。前面介绍了这些设计上的一些原则,但是你有没有发现,我一直说的是“尽量”而不是“绝对”?我想你肯定会问是不是请求最少就一定最好,我的答案是“不一定”。我们曾经把有些 CSS 内联进页面里,这样做可以减少依赖一个 CSS 的请求从而加快首页的渲染,但是同样也增大了页面的大小,又不符合“数据要尽量少”的原则,这种情况下我们为了提升首屏的渲染速度,只把首屏的 HTML 依赖的 CSS 内联进来,其他 CSS 仍然放到文件中作为依赖加载,尽量实现首屏的打开速度与整个页面加载性能的平衡。所以说,架构是一种平衡的艺术,而最好的架构一旦脱离了它所适应的场景,一切都将是空谈。我希望你记住的是,这里所说的几点都只是一个个方向,你应该尽量往这些方向上去努力,但也要考虑平衡其他因素。

8.1.2、不同场景下的不同架构案例

前面说了一些架构上的原则,那么针对“秒杀”这个场景,怎样才是一个好的架构呢?下面以淘宝早期秒杀系统架构的演进为主线,来梳理不同的请求体量下,我认为的最佳秒杀系统架构。

如果你想快速搭建一个简单的秒杀系统,只需要把你的商品购买页面增加一个“定时上架”功能,仅在秒杀开始时才让用户看到购买按钮,当商品的库存卖完了也就结束了。这就是当时第一个版本的秒杀系统实现方式。但随着请求量的加大(比如从 1w/s 到了 10w/s 的量级),这个简单的架构很快就遇到了瓶颈,因此需要做架构改造来提升系统性能。
这些架构改造包括:

  • 把秒杀系统独立出来单独打造一个系统,这样可以有针对性地做优化,例如这个独立出来的系统就减少了店铺装修的功能,减少了页面的复杂度;
  • 在系统部署上也独立做一个机器集群,这样秒杀的大流量就不会影响到正常的商品购买集群的机器负载;
  • 将热点数据(如库存数据)单独放到一个缓存系统中,以提高“读性能”;
  • 增加秒杀答题,防止有秒杀器抢单。

此时的系统架构变成了下图这个样子。最重要的就是,秒杀详情成为了一个独立的新系统,另外核心的一些数据放到了缓存(Cache)中,其他的关联系统也都以独立集群的方式进行部署。
在这里插入图片描述
然而这个架构仍然支持不了超过 100w/s 的请求量,所以为了进一步提升秒杀系统的性能,我们又对架构做进一步升级,比如:

  • 对页面进行彻底的动静分离,使得用户秒杀时不需要刷新整个页面,而只需要点击抢宝按钮,借此把页面刷新的数据降到最少;
  • 在服务端对秒杀商品进行本地缓存,不需要再调用依赖系统的后台服务获取数据,甚至不需要去公共的缓存集群中查询数据,这样不仅可以减少系统调用,而且能够避免压垮公共缓存集群。
  • 增加系统限流保护,防止最坏情况发生。

经过这些优化,系统架构变成了下图中的样子。在这里,我们对页面进行了进一步的静态化,秒杀过程中不需要刷新整个页面,而只需要向服务端请求很少的动态数据。而且,最关键的详情和交易系统都增加了本地缓存,来提前缓存秒杀商品的信息,热点数据库也做了独立部署,等等。
在这里插入图片描述
从前面的几次升级来看,其实越到后面需要定制的地方越多,也就是越“不通用”。例如,把秒杀商品缓存在每台机器的内存中,这种方式显然不适合太多的商品同时进行秒杀的情况,因为单机的内存始终有限。所以要取得极致的性能,就要在其他地方(比如,通用性、易用性、成本等方面)有所牺牲。

8.2、如何才能做好动静分离?有哪些方案可选?

数据的动静分离。不知道你之前听过这个解决方案吗?不管你有没有听过,我都建议你先停下来思考动静分离的价值。如果你的系统还没有开始应用动静分离的方案,那你也可以想想为什么没有,是之前没有想到,还是说业务体量根本用不着?不过我可以确信地说,如果你在一个业务飞速发展的公司里,并且你在深度参与公司内类秒杀类系统的架构或者开发工作,那么你迟早会想到动静分离的方案。为什么?很简单,秒杀的场景中,对于系统的要求其实就三个字:快、准、稳。

那怎么才能“快”起来呢?我觉得抽象起来讲,就只有两点,一点是提高单次请求的效率,一点是减少没必要的请求。今天我们聊到的“动静分离”其实就是瞄着这个大方向去的。

不知道你是否还记得,最早的秒杀系统其实是要刷新整体页面的,但后来秒杀的时候,你只要点击“刷新抢宝”按钮就够了,这种变化的本质就是动静分离,分离之后,客户端大幅度减少了请求的数据量。这不自然就“快”了吗?

8.2.1、何为动静数据

那到底什么才是动静分离呢?所谓“动静分离”,其实就是把用户请求的数据(如 HTML 页面)划分为“动态数据”和“静态数据”。

简单来说,“动态数据”和“静态数据”的主要区别就是看页面中输出的数据是否和 URL、浏览者、时间、地域相关,以及是否含有 Cookie 等私密数据。比如说:

很多媒体类的网站,某一篇文章的内容不管是你访问还是我访问,它都是一样的。所以它就是一个典型的静态数据,但是它是个动态页面。

我们如果现在访问淘宝的首页,每个人看到的页面可能都是不一样的,淘宝首页中包含了很多根据访问者特征推荐的信息,而这些个性化的数据就可以理解为动态数据了。

这里再强调一下,我们所说的静态数据,不能仅仅理解为传统意义上完全存在磁盘上的 HTML 页面,它也可能是经过 Java 系统产生的页面,但是它输出的页面本身不包含上面所说的那些因素。也就是所谓“动态”还是“静态”,并不是说数据本身是否动静,而是数据中是否含有和访问者相关的个性化数据。

还有一点要注意,就是页面中“不包含”,指的是“页面的 HTML 源码中不含有”,这一点务必要清楚。理解了静态数据和动态数据,我估计你很容易就能想明白“动静分离”这个方案的来龙去脉了。分离了动静数据,我们就可以对分离出来的静态数据做缓存,有了缓存之后,静态数据的“访问效率”自然就提高了。
那么,怎样对静态数据做缓存呢?我在这里总结了几个重点。

  • 第一,你应该把静态数据缓存到离用户最近的地方。静态数据就是那些相对不会变化的数据,因此我们可以把它们缓存起来。缓存到哪里呢?常见的就三种,用户浏览器里、CDN 上或者在服务端的 Cache 中。你应该根据情况,把它们尽量缓存到离用户最近的地方。

  • 第二,静态化改造就是要直接缓存 HTTP 连接。相较于普通的数据缓存而言,你肯定还听过系统的静态化改造。静态化改造是直接缓存 HTTP 连接而不是仅仅缓存数据,如下图所示,Web 代理服务器根据请求 URL,直接取出对应的 HTTP 响应头和响应体然后直接返回,这个响应过程简单得连 HTTP 协议都不用重新组装,甚至连 HTTP 请求头也不需要解析。

在这里插入图片描述

  • 第三,让谁来缓存静态数据也很重要。不同语言写的 Cache 软件处理缓存数据的效率也各不相同。以 Java 为例,因为 Java 系统本身也有其弱点(比如不擅长处理大量连接请求,每个连接消耗的内存较多,Servlet 容器解析 HTTP 协议较慢),所以你可以不在 Java 层做缓存,而是直接在 Web 服务器层上做,这样你就可以屏蔽 Java 语言层面的一些弱点;而相比起来,Web 服务器(如 Nginx、Apache、Varnish)也更擅长处理大并发的静态文件请求。

8.2.2、如何做动静分离的改造

理解了动静态数据的“why”和“what”,接下来我们就要看“how”了。我们如何把动态页面改造成适合缓存的静态页面呢?其实也很简单,就是去除前面所说的那几个影响因素,把它们单独分离出来,做动静分离。

下面,我以典型的商品详情系统为例来详细介绍。这里,你可以先打开京东或者淘宝的商品详情页,看看这个页面里都有哪些动静数据。我们从以下 5 个方面来分离出动态内容。

  • URL 唯一化。商品详情系统天然地就可以做到 URL 唯一化,比如每个商品都由 ID 来标识,那么 http://item.xxx.com/item.htm?id=xxxx就可以作为唯一的 URL 标识。为啥要 URL 唯一呢?前面说了我们是要缓存整个 HTTP 连接,那么以什么作为 Key 呢?就以 URL 作为缓存的 Key,例如以 id=xxx 这个格式进行区分。
  • 分离浏览者相关的因素。浏览者相关的因素包括是否已登录,以及登录身份等,这些相关因素我们可以单独拆分出来,通过动态请求来获取。
  • 分离时间因素。服务端输出的时间也通过动态请求获取。
  • 异步化地域因素。详情页面上与地域相关的因素做成异步方式获取,当然你也可以通过动态请求方式获取,只是这里通过异步获取更合适。
  • 去掉 Cookie。服务端输出的页面包含的 Cookie 可以通过代码软件来删除,如 Web 服务器 Varnish 可以通过 unset req.http.cookie 命令去掉 Cookie。注意,这里说的去掉 Cookie 并不是用户端收到的页面就不含 Cookie 了,而是说,在缓存的静态数据中不含有 Cookie。

分离出动态内容之后,如何组织这些内容页就变得非常关键了。这里我要提醒你一点,因为这其中很多动态内容都会被页面中的其他模块用到,如判断该用户是否已登录、用户 ID 是否匹配等,所以这个时候我们应该将这些信息 JSON 化(用 JSON 格式组织这些数据),以方便前端获取。

前面我们介绍里用缓存的方式来处理静态数据。而动态内容的处理通常有两种方案:ESI(Edge Side Includes)方案和 CSI(Client Side Include)方案。

  • ESI 方案(或者 SSI):即在 Web 代理服务器上做动态内容请求,并将请求插入到静态页面中,当用户拿到页面时已经是一个完整的页面了。这种方式对服务端性能有些影响,但是用户体验较好。

  • CSI 方案。即单独发起一个异步 JavaScript 请求,以向服务端获取动态内容。这种方式服务端性能更佳,但是用户端页面可能会延时,体验稍差。

8.2.3、动静分离的几种架构方案

前面我们通过改造把静态数据和动态数据做了分离,那么如何在系统架构上进一步对这些动态和静态数据重新组合,再完整地输出给用户呢?这就涉及对用户请求路径进行合理的架构了。根据架构上的复杂度,有 3 种方案可选:实体机单机部署;统一 Cache 层;上 CDN。

8.2.3.1、方案 1:实体机单机部署

这种方案是将虚拟机改为实体机,以增大 Cache 的容量,并且采用了一致性 Hash 分组的方式来提升命中率。这里将 Cache 分成若干组,是希望能达到命中率和访问热点的平衡。Hash 分组越少,缓存的命中率肯定就会越高,但短板是也会使单个商品集中在一个分组中,容易导致 Cache 被击穿,所以我们应该适当增加多个相同的分组,来平衡访问热点和命中率的问题。这里我给出了实体机单机部署方案的结构图,如下:
在这里插入图片描述
实体机单机部署有以下几个优点:

  • 没有网络瓶颈,而且能使用大内存;
  • 既能提升命中率,又能减少 Gzip 压缩;
  • 减少 Cache 失效压力,因为采用定时失效方式,例如只缓存 3 秒钟,过期即自动失效。

这个方案中,虽然把通常只需要虚拟机或者容器运行的 Java 应用换成实体机,优势很明显,它会增加单机的内存容量,但是一定程度上也造成了 CPU 的浪费,因为单个的 Java 进程很难用完整个实体机的 CPU。

另外就是,一个实体机上部署了 Java 应用又作为 Cache 来使用,这造成了运维上的高复杂度,所以这是一个折中的方案。如果你的公司里,没有更多的系统有类似需求,那么这样做也比较合适,如果你们有多个业务系统都有静态化改造的需求,那还是建议把 Cache 层单独抽出来公用比较合理,如下面的方案 2 所示。

8.2.3.2、方案 2:统一 Cache 层

所谓统一 Cache 层,就是将单机的 Cache 统一分离出来,形成一个单独的 Cache 集群。统一 Cache 层是个更理想的可推广方案,该方案的结构图如下:
在这里插入图片描述
将 Cache 层单独拿出来统一管理可以减少运维成本,同时也方便接入其他静态化系统。此外,它还有一些优点。

单独一个 Cache 层,可以减少多个应用接入时使用 Cache 的成本。这样接入的应用只要维护自己的 Java 系统就好,不需要单独维护 Cache,而只关心如何使用即可。

统一 Cache 的方案更易于维护,如后面加强监控、配置的自动化,只需要一套解决方案就行,统一起来维护升级也比较方便。
可以共享内存,最大化利用内存,不同系统之间的内存可以动态切换,从而能够有效应对各种攻击。

这种方案虽然维护上更方便了,但是也带来了其他一些问题,比如缓存更加集中,导致:

  • Cache 层内部交换网络成为瓶颈;
  • 缓存服务器的网卡也会是瓶颈;
  • 机器少风险较大,挂掉一台就会影响很大一部分缓存数据。

要解决上面这些问题,可以再对 Cache 做 Hash 分组,即一组 Cache 缓存的内容相同,这样能够避免热点数据过度集中导致新的瓶颈产生。

8.2.3.3、方案 3:上 CDN

在将整个系统做动静分离后,我们自然会想到更进一步的方案,就是将 Cache 进一步前移到 CDN 上,因为 CDN 离用户最近,效果会更好。
但是要想这么做,有以下几个问题需要解决。

  • 失效问题。前面我们也有提到过缓存时效的问题,不知道你有没有理解,我再来解释一下。谈到静态数据时,我说过一个关键词叫“相对不变”,它的言外之意是“可能会变化”。比如一篇文章,现在不变,但如果你发现个错别字,是不是就会变化了?如果你的缓存时效很长,那用户端在很长一段时间内看到的都是错的。所以,这个方案中也是,我们需要保证 CDN 可以在秒级时间内,让分布在全国各地的 Cache 同时失效,这对 CDN 的失效系统要求很高。

  • 命中率问题。Cache 最重要的一个衡量指标就是“高命中率”,不然 Cache 的存在就失去了意义。同样,如果将数据全部放到全国的 CDN 上,必然导致 Cache 分散,而 Cache 分散又会导致访问请求命中同一个 Cache 的可能性降低,那么命中率就成为一个问题。

  • 发布更新问题。如果一个业务系统每周都有日常业务需要发布,那么发布系统必须足够简洁高效,而且你还要考虑有问题时快速回滚和排查问题的简便性。

从前面的分析来看,将商品详情系统放到全国的所有 CDN 节点上是不太现实的,因为存在失效问题、命中率问题以及系统的发布更新问题。那么是否可以选择若干个节点来尝试实施呢?答案是“可以”,但是这样的节点需要满足几个条件:

  • 靠近访问量比较集中的地区;
  • 离主站相对较远;
  • 节点到主站间的网络比较好,而且稳定;
  • 节点容量比较大,不会占用其他 CDN 太多的资源。

最后,还有一点也很重要,那就是:节点不要太多。基于上面几个因素,选择 CDN 的二级 Cache 比较合适,因为二级 Cache 数量偏少,容量也更大,让用户的请求先回源的 CDN 的二级 Cache 中,如果没命中再回源站获取数据
部署方式如下图所示:

在这里插入图片描述
使用 CDN 的二级 Cache 作为缓存,可以达到和当前服务端静态化 Cache 类似的命中率,因为节点数不多,Cache 不是很分散,访问量也比较集中,这样也就解决了命中率问题,同时能够给用户最好的访问体验,是当前比较理想的一种 CDN 化方案。

除此之外,CDN 化部署方案还有以下几个特点:

  • 把整个页面缓存在用户浏览器中;
  • 如果强制刷新整个页面,也会请求 CDN;
  • 实际有效请求,只是用户对“刷新抢宝”按钮的点击。

这样就把 90% 的静态数据缓存在了用户端或者 CDN 上,当真正秒杀时,用户只需要点击特殊的“刷新抢宝”按钮,而不需要刷新整个页面。这样一来,系统只是向服务端请求很少的有效数据,而不需要重复请求大量的静态数据。秒杀的动态数据和普通详情页面的动态数据相比更少,性能也提升了 3 倍以上。

所以“抢宝”这种设计思路,让我们不用刷新页面就能够很好地请求到服务端最新的动态数据。

8.3、二八原则:有针对性地处理好系统的“热点数据”

8.3.1、什么是“热点”

热点分为热点操作和热点数据。所谓“热点操作”,例如大量的刷新页面、大量的添加购物车、双十一零点大量的下单等都属于此类操作。对系统来说,这些操作可以抽象为“读请求”和“写请求”,这两种热点请求的处理方式大相径庭,读请求的优化空间要大一些,而写请求的瓶颈一般都在存储层,优化的思路就是根据 CAP 理论做平衡,这个内容我在“减库存”一文再详细介绍。

而“热点数据”比较好理解,那就是用户的热点请求对应的数据。而热点数据又分为“静态热点数据”和“动态热点数据”

  • 所谓“静态热点数据”,就是能够提前预测的热点数据。例如,我们可以通过卖家报名的方式提前筛选出来,通过报名系统对这些热点商品进行打标。另外,我们还可以通过大数据分析来提前发现热点商品,比如我们分析历史成交记录、用户的购物车记录,来发现哪些商品可能更热门、更好卖,这些都是可以提前分析出来的热点。

  • 所谓“动态热点数据”,就是不能被提前预测到的,系统在运行过程中临时产生的热点。例如,卖家在抖音上做了广告,然后商品一下就火了,导致它在短时间内被大量购买。
    由于热点操作是用户的行为,我们不好改变,但能做一些限制和保护,所以本文我主要针对热点数据来介绍如何进行优化。

8.3.2、发现热点数据

前面,我介绍了如何对单个秒杀商品的页面数据进行动静分离,以便针对性地对静态数据做优化处理,那么另外一个关键的问题来了:如何发现这些秒杀商品,或者更准确地说,如何发现热点商品呢?

你可能会说“参加秒杀的商品就是秒杀商品啊”,没错,关键是系统怎么知道哪些商品参加了秒杀活动呢?所以,你要有一个机制提前来区分普通商品和秒杀商品。

我们从发现静态热点和发现动态热点两个方面来看一下。

8.3.2.1、发现静态热点数据

如前面讲的,静态热点数据可以通过商业手段,例如强制让卖家通过报名参加的方式提前把热点商品筛选出来,实现方式是通过一个运营系统,把参加活动的商品数据进行打标,然后通过一个后台系统对这些热点商品进行预处理,如提前进行缓存。但是这种通过报名提前筛选的方式也会带来新的问题,即增加卖家的使用成本,而且实时性较差,也不太灵活。不过,除了提前报名筛选这种方式,你还可以通过技术手段提前预测,例如对买家每天访问的商品进行大数据计算,然后统计出 TOP N 的商品,我们可以认为这些 TOP N 的商品就是热点商品。

8.3.2.2、发现动态热点数据

我们可以通过卖家报名或者大数据预测这些手段来提前预测静态热点数据,但这其中有一个痛点,就是实时性较差,如果我们的系统能在秒级内自动发现热点商品那就完美了。能够动态地实时发现热点不仅对秒杀商品,对其他热卖商品也同样有价值,所以我们需要想办法实现热点的动态发现功能。

这里我给出一个动态热点发现系统的具体实现。

  • 1、构建一个异步的系统,它可以收集交易链路上各个环节中的中间件产品的热点 Key,如 Nginx、缓存、RPC 服务框架等这些中间件(一些中间件产品本身已经有热点统计模块)。
  • 2、建立一个热点上报和可以按照需求订阅的热点服务的下发规范,主要目的是通过交易链路上各个系统(包括详情、购物车、交易、优惠、库存、物流等)访问的时间差,把上游已经发现的热点透传给下游系统,提前做好保护。比如,对于大促高峰期,详情系统是最早知道的,在统一接入层上 Nginx 模块统计的热点 URL。
  • 3、将上游系统收集的热点数据发送到热点服务台,然后下游系统(如交易系统)就会知道哪些商品会被频繁调用,然后做热点保护。

这里我给出了一个图,其中用户访问商品时经过的路径有很多,我们主要是依赖前面的导购页面(包括首页、搜索页面、商品详情、购物车等)提前识别哪些商品的访问量高,通过这些系统中的中间件来收集热点数据,并记录到日志中。
在这里插入图片描述
我们通过部署在每台机器上的 Agent 把日志汇总到聚合和分析集群中,然后把符合一定规则的热点数据,通过订阅分发系统再推送到相应的系统中。你可以是把热点数据填充到 Cache 中,或者直接推送到应用服务器的内存中,还可以对这些数据进行拦截,总之下游系统可以订阅这些数据,然后根据自己的需求决定如何处理这些数据。

打造热点发现系统时,我根据以往经验总结了几点注意事项。

1、这个热点服务后台抓取热点数据日志最好采用异步方式,因为“异步”一方面便于保证通用性,另一方面又不影响业务系统和中间件产品的主流程。
2、热点服务发现和中间件自身的热点保护模块并存,每个中间件和应用还需要保护自己。热点服务台提供热点数据的收集和订阅服务,便于把各个系统的热点数据透明出来。
3、热点发现要做到接近实时(3s 内完成热点数据的发现),因为只有做到接近实时,动态发现才有意义,才能实时地对下游系统提供保护。

8.3.3、处理热点数据

处理热点数据通常有几种思路:一是优化,二是限制,三是隔离。

8.3.3.1、优化

优化热点数据最有效的办法就是缓存热点数据,如果热点数据做了动静分离,那么可以长期缓存静态数据。但是,缓存热点数据更多的是“临时”缓存,即不管是静态数据还是动态数据,都用一个队列短暂地缓存数秒钟,由于队列长度有限,可以采用 LRU 淘汰算法替换。

8.3.3.2、限制

限制更多的是一种保护机制,限制的办法也有很多,例如对被访问商品的 ID 做一致性 Hash,然后根据 Hash 做分桶,每个分桶设置一个处理队列,这样可以把热点商品限制在一个请求队列里,防止因某些热点商品占用太多的服务器资源,而使其他请求始终得不到服务器的处理资源。

8.3.3.3、隔离

秒杀系统设计的第一个原则就是将这种热点数据隔离出来,不要让 1% 的请求影响到另外的 99%,隔离出来后也更方便对这 1% 的请求做针对性的优化。

具体到“秒杀”业务,我们可以在以下几个层次实现隔离。

  • 业务隔离。把秒杀做成一种营销活动,卖家要参加秒杀这种营销活动需要单独报名,从技术上来说,卖家报名后对我们来说就有了已知热点,因此可以提前做好预热。
  • 系统隔离。系统隔离更多的是运行时的隔离,可以通过分组部署的方式和另外 99% 分开。秒杀可以申请单独的域名,目的也是让请求落到不同的集群中。
  • 数据隔离。秒杀所调用的数据大部分都是热点数据,比如会启用单独的 Cache 集群或者 MySQL 数据库来放热点数据,目的也是不想 0.01% 的数据有机会影响 99.99% 数据。

8.4、流量削峰这事应该怎么做?

8.4.1、为什么要削峰?

我们知道服务器的处理资源是恒定的,你用或者不用它的处理能力都是一样的,所以出现峰值的话,很容易导致忙到处理不过来,闲的时候却又没有什么要处理。但是由于要保证服务质量,我们的很多处理资源只能按照忙的时候来预估,而这会导致资源的一个浪费。

这就好比因为存在早高峰和晚高峰的问题,所以有了错峰限行的解决方案。削峰的存在,一是可以让服务端处理变得更加平稳,二是可以节省服务器的资源成本。针对秒杀这一场景,削峰从本质上来说就是更多地延缓用户请求的发出,以便减少和过滤掉一些无效请求,它遵从“请求数要尽量少”的原则。

今天,我就来介绍一下流量削峰的一些操作思路:排队、答题、分层过滤。这几种方式都是无损(即不会损失用户的发出请求)的实现方案,当然还有些有损的实现方案,包括我们后面要介绍的关于稳定性的一些办法,比如限流和机器负载保护等一些强制措施也能达到削峰保护的目的,当然这都是不得已的一些措施,因此就不归类到这里了

8.4.2、排队

要对流量进行削峰,最容易想到的解决方案就是用消息队列来缓冲瞬时流量,把同步的直接调用转换成异步的间接推送,中间通过一个队列在一端承接瞬时的流量洪峰,在另一端平滑地将消息推送出去。在这里,消息队列就像“水库”一样, 拦蓄上游的洪水,削减进入下游河道的洪峰流量,从而达到减免洪水灾害的目的。用消息队列来缓冲瞬时流量的方案,如下图所示:
在这里插入图片描述
但是,如果流量峰值持续一段时间达到了消息队列的处理上限,例如本机的消息积压达到了存储空间的上限,消息队列同样也会被压垮,这样虽然保护了下游的系统,但是和直接把请求丢弃也没多大的区别。就像遇到洪水爆发时,即使是有水库恐怕也无济于事

除了消息队列,类似的排队方式还有很多,例如:

  • 1、利用线程池加锁等待也是一种常用的排队方式;
  • 2、先进先出、先进后出等常用的内存排队算法的实现方式;
  • 3、把请求序列化到文件中,然后再顺序地读文件(例如基于 MySQL binlog 的同步机制)来恢复请求等方式。

8.4.3、答题

你是否还记得,最早期的秒杀只是纯粹地刷新页面和点击购买按钮,它是后来才增加了答题功能的。那么,为什么要增加答题功能呢?

这主要是为了增加购买的复杂度,从而达到两个目的。

第一个目的是防止部分买家使用秒杀器在参加秒杀时作弊。2011 年秒杀非常火的时候,秒杀器也比较猖獗,因而没有达到全民参与和营销的目的,所以系统增加了答题来限制秒杀器。增加答题后,下单的时间基本控制在 2s 后,秒杀器的下单比例也大大下降。答题页面如下图所示。
在这里插入图片描述
第二个目的其实就是延缓请求,起到对请求流量进行削峰的作用,从而让系统能够更好地支持瞬时的流量高峰。这个重要的功能就是把峰值的下单请求拉长,从以前的 1s 之内延长到 2s~10s。这样一来,请求峰值基于时间分片了。这个时间的分片对服务端处理并发非常重要,会大大减轻压力。而且,由于请求具有先后顺序,靠后的请求到来时自然也就没有库存了,因此根本到不了最后的下单步骤,所以真正的并发写就非常有限了。这种设计思路目前用得非常普遍,如当年支付宝的“咻一咻”、微信的“摇一摇”都是类似的方式。

这里,我重点说一下秒杀答题的设计思路。
在这里插入图片描述
如上图所示,整个秒杀答题的逻辑主要分为 3 部分。

  • 题库生成模块,这个部分主要就是生成一个个问题和答案,其实题目和答案本身并不需要很复杂,重要的是能够防止由机器来算出结果,即防止秒杀器来答题。
  • 题库的推送模块,用于在秒杀答题前,把题目提前推送给详情系统和交易系统。题库的推送主要是为了保证每次用户请求的题目是唯一的,目的也是防止答题作弊。
  • 题目的图片生成模块,用于把题目生成为图片格式,并且在图片里增加一些干扰因素。这也同样是为防止机器直接来答题,它要求只有人才能理解题目本身的含义。这里还要注意一点,由于答题时网络比较拥挤,我们应该把题目的图片提前推送到 CDN 上并且要进行预热,不然的话当用户真正请求题目时,图片可能加载比较慢,从而影响答题的体验。

其实真正答题的逻辑比较简单,很好理解:当用户提交的答案和题目对应的答案做比较,如果通过了就继续进行下一步的下单逻辑,否则就失败。我们可以把问题和答案用下面这样的 key 来进行 MD5 加密:

问题 key:userId+itemId+question_Id+time+PK
答案 key:userId+itemId+answer+PK

验证的逻辑如下图所示:
在这里插入图片描述
注意,这里面的验证逻辑,除了验证问题的答案以外,还包括用户本身身份的验证,例如是否已经登录、用户的 Cookie 是否完整、用户是否重复频繁提交等。

除了做正确性验证,我们还可以对提交答案的时间做些限制,例如从开始答题到接受答案要超过 1s,因为小于 1s 是人为操作的可能性很小,这样也能防止机器答题的情况。

8.4.4、分层过滤

前面介绍的排队和答题要么是少发请求,要么对发出来的请求进行缓冲,而针对秒杀场景还有一种方法,就是对请求进行分层过滤,从而过滤掉一些无效的请求。分层过滤其实就是采用“漏斗”式设计来处理请求的,如下图所示。
在这里插入图片描述
假如请求分别经过 CDN、前台读系统(如商品详情系统)、后台系统(如交易系统)和数据库这几层,那么:

  • 大部分数据和流量在用户浏览器或者 CDN 上获取,这一层可以拦截大部分数据的读取;
  • 经过第二层(即前台系统)时数据(包括强一致性的数据)尽量得走 Cache,过滤一些无效的请求;
  • 再到第三层后台系统,主要做数据的二次检验,对系统做好保护和限流,这样数据量和请求就进一步减少;
  • 最后在数据层完成数据的强一致性校验。

这样就像漏斗一样,尽量把数据量和请求量一层一层地过滤和减少了。

分层过滤的核心思想是:在不同的层次尽可能地过滤掉无效请求,让“漏斗”最末端的才是有效请求。而要达到这种效果,我们就必须对数据做分层的校验。

分层校验的基本原则是:

  • 将动态请求的读数据缓存(Cache)在 Web 端,过滤掉无效的数据读;
  • 对读数据不做强一致性校验,减少因为一致性校验产生瓶颈的问题;
  • 对写数据进行基于时间的合理分片,过滤掉过期的失效请求;
  • 对写请求做限流保护,将超出系统承载能力的请求过滤掉;
  • 对写数据进行强一致性校验,只保留最后有效的数据。

8.5、影响性能的因素有哪些?又该如何提高系统的性能?

8.5.1、影响性能的因素

那么,哪些因素对性能有影响呢?在回答这个问题之前,我们先定义一下“性能”,服务设备不同对性能的定义也是不一样的,例如 CPU 主要看主频、磁盘主要看 IOPS(Input/Output Operations Per Second,即每秒进行读写操作的次数)。

而今天我们讨论的主要是系统服务端性能,一般用 QPS(Query Per Second,每秒请求数)来衡量,还有一个影响和 QPS 也息息相关,那就是响应时间(Response Time,RT),它可以理解为服务器处理响应的耗时。

正常情况下响应时间(RT)越短,一秒钟处理的请求数(QPS)自然也就会越多,这在单线程处理的情况下看起来是线性的关系,即我们只要把每个请求的响应时间降到最低,那么性能就会最高。

但是你可能想到响应时间总有一个极限,不可能无限下降,所以又出现了另外一个维度,即通过多线程,来处理请求。这样理论上就变成了“总 QPS =(1000ms / 响应时间)× 线程数量”,这样性能就和两个因素相关了,一个是一次响应的服务端耗时,一个是处理请求的线程数。

首先,我们先来看看响应时间和 QPS 有啥关系。

对于大部分的 Web 系统而言,响应时间一般都是由 CPU 执行时间和线程等待时间(比如 RPC、IO 等待、Sleep、Wait 等)组成,即服务器在处理一个请求时,一部分是 CPU 本身在做运算,还有一部分是在各种等待。

如果代理服务器本身没有 CPU 消耗,我们在每次给代理服务器代理的请求加个延时,即增加响应时间,但是这对代理服务器本身的吞吐量并没有多大的影响,因为代理服务器本身的资源并没有被消耗,可以通过增加代理服务器的处理线程数,来弥补响应时间对代理服务器的 QPS 的影响。

其实,真正对性能有影响的是 CPU 的执行时间。这也很好理解,因为 CPU 的执行真正消耗了服务器的资源。经过实际的测试,如果减少 CPU 一半的执行时间,就可以增加一倍的 QPS。

也就是说,我们应该致力于减少 CPU 的执行时间。

其次,我们再来看看线程数对 QPS 的影响。

单看“总 QPS”的计算公式,你会觉得线程数越多 QPS 也就会越高,但这会一直正确吗?显然不是,线程数不是越多越好,因为线程本身也消耗资源,也受到其他因素的制约。例如,线程越多系统的线程切换成本就会越高,而且每个线程也都会耗费一定内存。

那么,设置什么样的线程数最合理呢?其实很多多线程的场景都有一个默认配置,即“线程数 = 2 * CPU 核数 + 1”。除去这个配置,还有一个根据最佳实践得出来的公式:

线程数 = [(线程等待时间 + 线程 CPU 时间) / 线程 CPU 时间] × CPU 数量

当然,最好的办法是通过性能测试来发现最佳的线程数。

换句话说,要提升性能我们就要减少 CPU 的执行时间,另外就是要设置一个合理的并发线程数,通过这两方面来显著提升服务器的性能。

现在,你知道了如何来快速提升性能,那接下来你估计会问,我应该怎么发现系统哪里最消耗 CPU 资源呢?

8.5.2、如何发现瓶颈

就服务器而言,会出现瓶颈的地方有很多,例如 CPU、内存、磁盘以及网络等都可能会导致瓶颈。此外,不同的系统对瓶颈的关注度也不一样,例如对缓存系统而言,制约它的是内存,而对存储型系统来说 I/O 更容易是瓶颈。

我们定位的场景是秒杀,它的瓶颈更多地发生在 CPU 上。

怎样简单地判断 CPU 是不是瓶颈呢?一个办法就是看当 QPS 达到极限时,你的服务器的 CPU 使用率是不是超过了 95%,如果没有超过,那么表示 CPU 还有提升的空间,要么是有锁限制,要么是有过多的本地 I/O 等待发生。

8.5.3、如何优化系统

对 Java 系统来说,可以优化的地方很多,这里我重点说一下比较有效的几种手段,供你参考,它们是:减少编码、减少序列化、Java 极致优化、并发读优化。接下来,我们分别来看一下。

8.5.3.1、减少编码

Java 的编码运行比较慢,这是 Java 的一大硬伤。在很多场景下,只要涉及字符串的操作(如输入输出操作、I/O 操作)都比较耗 CPU 资源,不管它是磁盘 I/O 还是网络 I/O,因为都需要将字符转换成字节,而这个转换必须编码。每个字符的编码都需要查表,而这种查表的操作非常耗资源,所以减少字符到字节或者相反的转换、减少字符编码会非常有成效。减少编码就可以大大提升性能

8.5.3.2、减少序列化

序列化也是 Java 性能的一大天敌,减少 Java 中的序列化操作也能大大提升性能。又因为序列化往往是和编码同时发生的,所以减少序列化也就减少了编码。

序列化大部分是在 RPC 中发生的,因此避免或者减少 RPC 就可以减少序列化,当然当前的序列化协议也已经做了很多优化来提升性能。有一种新的方案,就是可以将多个关联性比较强的应用进行“合并部署”,而减少不同应用之间的 RPC 也可以减少序列化的消耗。

所谓“合并部署”,就是把两个原本在不同机器上的不同应用合并部署到一台机器上,当然不仅仅是部署在一台机器上,还要在同一个 Tomcat 容器中,且不能走本机的 Socket,这样才能避免序列化的产生。

另外针对秒杀场景,我们还可以做得更极致一些,接下来我们来看第 3 点:Java 极致优化。

8.5.3.3、Java 极致优化

Java 和通用的 Web 服务器(如 Nginx 或 Apache 服务器)相比,在处理大并发的 HTTP 请求时要弱一点,所以一般我们都会对大流量的 Web 系统做静态化改造,让大部分请求和数据直接在 Nginx 服务器或者 Web 代理服务器(如 Varnish、Squid 等)上直接返回(这样可以减少数据的序列化与反序列化),而 Java 层只需处理少量数据的动态请求。针对这些请求,我们可以使用以下手段进行优化:

  • 1、直接使用 Servlet 处理请求。避免使用传统的 MVC 框架,这样可以绕过一大堆复杂且用处不大的处理逻辑,节省 1ms 时间(具体取决于你对 MVC 框架的依赖程度)
  • 2、直接输出流数据。使用 resp.getOutputStream() 而不是 resp.getWriter() 函数,可以省掉一些不变字符数据的编码,从而提升性能;数据输出时推荐使用 JSON 而不是模板引擎(一般都是解释执行)来输出页面。
8.5.3.4、并发读优化

也许有读者会觉得这个问题很容易解决,无非就是放到 Tair 缓存里面。集中式缓存为了保证命中率一般都会采用一致性 Hash,所以同一个 key 会落到同一台机器上。虽然单台缓存机器也能支撑 30w/s 的请求,但还是远不足以应对像“大秒”这种级别的热点商品。那么,该如何彻底解决单点的瓶颈呢?

答案是采用应用层的 LocalCache,即在秒杀系统的单机上缓存商品相关的数据。

那么,又如何缓存(Cache)数据呢?你需要划分成动态数据和静态数据分别进行处理:

  • 1、像商品中的“标题”和“描述”这些本身不变的数据,会在秒杀开始之前全量推送到秒杀机器上,并一直缓存到秒杀结束;

  • 2、像库存这类动态数据,会采用“被动失效”的方式缓存一定时间(一般是数秒),失效后再去缓存拉取最新的数据。

8.6、秒杀系统“减库存”设计的核心逻辑

千万不要超卖,这是大前提。

8.6.1、减库存有哪几种方式

在正常的电商平台购物场景中,用户的实际购买过程一般分为两步:下单和付款。你想买一台 iPhone 手机,在商品页面点了“立即购买”按钮,核对信息之后点击“提交订单”,这一步称为下单操作。下单之后,你只有真正完成付款操作才能算真正购买,也就是俗话说的“落袋为安”。

那如果你是架构师,你会在哪个环节完成减库存的操作呢?总结来说,减库存操作一般有如下几个方式:

  • 下单减库存,即当买家下单后,在商品的总库存中减去买家购买数量。下单减库存是最简单的减库存方式,也是控制最精确的一种,下单时直接通过数据库的事务机制控制商品库存,这样一定不会出现超卖的情况。但是你要知道,有些人下完单可能并不会付款。
  • 付款减库存,即买家下单后,并不立即减库存,而是等到有用户付款后才真正减库存,否则库存一直保留给其他买家。但因为付款时才减库存,如果并发比较高,有可能出现买家下单后付不了款的情况,因为可能商品已经被其他人买走了
  • 预扣库存,这种方式相对复杂一些,买家下单后,库存为其保留一定的时间(如 10 分钟),超过这个时间,库存将会自动释放,释放后其他买家就可以继续购买。在买家付款前,系统会校验该订单的库存是否还有保留:如果没有保留,则再次尝试预扣;如果库存不足(也就是预扣失败)则不允许继续付款;如果预扣成功,则完成付款并实际地减去库存。

以上这几种减库存的方式都会存在一些问题,下面我们一起来看下。

8.6.2、减库存可能存在的问题

由于购物过程中存在两步或者多步的操作,因此在不同的操作步骤中减库存,就会存在一些可能被恶意买家利用的漏洞,例如发生恶意下单的情况。

假如我们采用“下单减库存”的方式,即用户下单后就减去库存,正常情况下,买家下单后付款的概率会很高,所以不会有太大问题。但是有一种场景例外,就是当卖家参加某个活动时,此时活动的有效时间是商品的黄金售卖时间,如果有竞争对手通过恶意下单的方式将该卖家的商品全部下单,让这款商品的库存减为零,那么这款商品就不能正常售卖了。要知道,这些恶意下单的人是不会真正付款的,这正是“下单减库存”方式的不足之处。

既然“下单减库存”可能导致恶意下单,从而影响卖家的商品销售,那么有没有办法解决呢?你可能会想,采用“付款减库存”的方式是不是就可以了?的确可以。但是,“付款减库存”又会导致另外一个问题:库存超卖。

假如有 100 件商品,就可能出现 300 人下单成功的情况,因为下单时不会减库存,所以也就可能出现下单成功数远远超过真正库存数的情况,这尤其会发生在做活动的热门商品上。这样一来,就会导致很多买家下单成功但是付不了款,买家的购物体验自然比较差。

可以看到,不管是“下单减库存”还是“付款减库存”,都会导致商品库存不能完全和实际售卖情况对应起来的情况,看来要把商品准确地卖出去还真是不容易啊!

那么,既然“下单减库存”和“付款减库存”都有缺点,我们能否把两者相结合,将两次操作进行前后关联起来,下单时先预扣,在规定时间内不付款再释放库存,即采用“预扣库存”这种方式呢?

这种方案确实可以在一定程度上缓解上面的问题。但是否就彻底解决了呢?其实没有!针对恶意下单这种情况,虽然把有效的付款时间设置为 10 分钟,但是恶意买家完全可以在 10 分钟后再次下单,或者采用一次下单很多件的方式把库存减完。针对这种情况,解决办法还是要结合安全和反作弊的措施来制止。

例如,给经常下单不付款的买家进行识别打标(可以在被打标的买家下单时不减库存)、给某些类目设置最大购买件数(例如,参加活动的商品一人最多只能买 3 件),以及对重复下单不付款的操作进行次数限制等。

针对“库存超卖”这种情况,在 10 分钟时间内下单的数量仍然有可能超过库存数量,遇到这种情况我们只能区别对待:对普通的商品下单数量超过库存数量的情况,可以通过补货来解决;但是有些卖家完全不允许库存为负数的情况,那只能在买家付款时提示库存不足。

8.6.3、大型秒杀中如何减库存?

目前来看,业务系统中最常见的就是预扣库存方案,像你在买机票、买电影票时,下单后一般都有个“有效付款时间”,超过这个时间订单自动释放,这都是典型的预扣库存方案。而具体到秒杀这个场景,应该采用哪种方案比较好呢?

由于参加秒杀的商品,一般都是“抢到就是赚到”,所以成功下单后却不付款的情况比较少,再加上卖家对秒杀商品的库存有严格限制,所以秒杀商品采用“下单减库存”更加合理。另外,理论上由于“下单减库存”比“预扣库存”以及涉及第三方支付的“付款减库存”在逻辑上更为简单,所以性能上更占优势。

“下单减库存”在数据一致性上,主要就是保证大并发请求时库存数据不能为负数,也就是要保证数据库中的库存字段值不能为负数,一般我们有多种解决方案:一种是在应用程序中通过事务来判断,即保证减后库存不能为负数,否则就回滚;另一种办法是直接设置数据库的字段数据为无符号整数,这样减后库存字段值小于零时会直接执行 SQL 语句来报错;再有一种就是使用 CASE WHEN 判断语句,例如这样的 SQL 语句:

UPDATE item SET inventory = CASE WHEN inventory >= xxx THEN inventory-xxx ELSE inventory END 

8.6.4、秒杀减库存的极致优化

在交易环节中,“库存”是个关键数据,也是个热点数据,因为交易的各个环节中都可能涉及对库存的查询。但是,我在前面介绍分层过滤时提到过,秒杀中并不需要对库存有精确的一致性读,把库存数据放到缓存(Cache)中,可以大大提升读性能。解决大并发读问题,可以采用 LocalCache(即在秒杀系统的单机上缓存商品相关的数据)和对数据进行分层过滤的方式,但是像减库存这种大并发写无论如何还是避免不了,这也是秒杀场景下最为核心的一个技术难题。

因此,这里我想专门来说一下秒杀场景下减库存的极致优化思路,包括如何在缓存中减库存以及如何在数据库中减库存。

秒杀商品和普通商品的减库存还是有些差异的,例如商品数量比较少,交易时间段也比较短,因此这里有一个大胆的假设,即能否把秒杀商品减库存直接放到缓存系统中实现,也就是直接在缓存中减库存或者在一个带有持久化功能的缓存系统(如 Redis)中完成呢?

如果你的秒杀商品的减库存逻辑非常单一,比如没有复杂的 SKU 库存和总库存这种联动关系的话,我觉得完全可以。但是如果有比较复杂的减库存逻辑,或者需要使用事务,你还是必须在数据库中完成减库存。

由于 MySQL 存储数据的特点,同一数据在数据库里肯定是一行存储(MySQL),因此会有大量线程来竞争 InnoDB 行锁,而并发度越高时等待线程会越多,TPS(Transaction Per Second,即每秒处理的消息数)会下降,响应时间(RT)会上升,数据库的吞吐量就会严重受影响。

这就可能引发一个问题,就是单个热点商品会影响整个数据库的性能, 导致 0.01% 的商品影响 99.99% 的商品的售卖,这是我们不愿意看到的情况。一个解决思路是遵循前面介绍的原则进行隔离,把热点商品放到单独的热点库中。但是这无疑会带来维护上的麻烦,比如要做热点数据的动态迁移以及单独的数据库等。

而分离热点商品到单独的数据库还是没有解决并发锁的问题,我们应该怎么办呢?要解决并发锁的问题,有两种办法:

  • 应用层做排队。按照商品维度设置队列顺序执行,这样能减少同一台机器对数据库同一行记录进行操作的并发度,同时也能控制单个商品占用数据库连接的数量,防止热点商品占用太多的数据库连接。
  • 数据库层做排队。应用层只能做到单机的排队,但是应用机器数本身很多,这种排队方式控制并发的能力仍然有限,所以如果能在数据库层做全局排队是最理想的。阿里的数据库团队开发了针对这种 MySQL 的 InnoDB 层上的补丁程序(patch),可以在数据库层上对单行记录做到并发排队。

你可能有疑问了,排队和锁竞争不都是要等待吗,有啥区别?

如果熟悉 MySQL 的话,你会知道 InnoDB 内部的死锁检测,以及 MySQL Server 和 InnoDB 的切换会比较消耗性能,淘宝的 MySQL 核心团队还做了很多其他方面的优化,如 COMMIT_ON_SUCCESS 和 ROLLBACK_ON_FAIL 的补丁程序,配合在 SQL 里面加提示(hint),在事务里不需要等待应用层提交(COMMIT),而在数据执行完最后一条 SQL 后,直接根据 TARGET_AFFECT_ROW 的结果进行提交或回滚,可以减少网络等待时间(平均约 0.7ms)。据我所知,目前阿里 MySQL 团队已经将包含这些补丁程序的 MySQL 开源。

另外,数据更新问题除了前面介绍的热点隔离和排队处理之外,还有些场景(如对商品的 lastmodifytime 字段的)更新会非常频繁,在某些场景下这些多条 SQL 是可以合并的,一定时间内只要执行最后一条 SQL 就行了,以便减少对数据库的更新操作。

8.7、E-R关系图

用于指导如何建立领域模型。从E-R图上我们能看出,几个比较重要的领域模型:如活动、活动商品,系统真正编码落地的时候,紧紧围绕这些领域模型去建模,做到代码和领域模型的表达一致的。

在这里插入图片描述

8.8、产品边界

概要设计的目的是为了明确产品功能和系统边界,通过领域驱动的界限上下文图,能清晰地看出完成当前需求需要参与协作的团队,以及团队与团队之间任务划分边界。活动上下文是我们关注的重点,同时也应该看到,我们需要商品团队、交易团队的协作。
在这里插入图片描述

8.9、秒杀流程图

在这里插入图片描述

8.10、秒杀架构总结

在这里插入图片描述

9、高并发场景缓存穿透&失效&雪崩如何解决

在这里插入图片描述
客户端发起一个查询请求的时候,首先去缓存中查询,如果数据在缓存中存在,则直接将缓存中的数据返回给客户端;如果数据在缓存中不存在,则继续查询数据库,如果数据在数据库中存在,则将该数据放入缓存中,并返回给客户端,如果数据在数据库中也不存在,则直接返回null给客户端。

9.1、缓存穿透

缓存穿透是指查询一个缓存中和数据库中都不存在的数据,导致每次查询这条数据都会透过缓存,直接查库,最后返回空。当用户使用这条不存在的数据疯狂发起查询请求的时候,对数据库造成的压力就非常大,甚至可能直接挂掉。这种情况的流程就变成下图这样了:
在这里插入图片描述

9.1.1、缓存穿透解决方案

解决缓存穿透的方法一般有两种

9.1.1.1、缓存空对象

就是当数据库中查不到数据的时候,我缓存一个空对象,然后给这个空对象的缓存设置一个过期时间,这样下次再查询该数据的时候,就可以直接从缓存中拿到,从而达到了减小数据库压力的目的。
但这种解决方式有两个缺点:

  • (1)需要缓存层提供更多的内存空间来缓存这些空对象,当这种空对象很多的时候,就会浪费更多的内存;
  • (2)会导致缓存层和存储层的数据不一致,即使在缓存空对象时给它设置了一个很短的过期时间,那也会导致这一段时间内的数据不一致问题。
9.1.1.2、使用布隆过滤器

这是比较推荐的方法。所谓布隆过滤器,就是一种数据结构,它是由一个长度为m bit的位数组与n个hash函数组成的数据结构,位数组中每个元素的初始值都是0。在初始化布隆过滤器时,会先将所有key进行n次hash运算,这样就可以得到n个位置,然后将这n个位置上的元素改为1。这样,就相当于把所有的key保存到了布隆过滤器中了。

举个例子,比如我们一共有3个key,我们对这3个key分别进行3次hash运算,key1经过三次hash运算后的结果分别为2/6/10,那么就把布隆过滤器中下标为2/6/10的元素值更新为1,然后再分别对key2和key3做同样操作,结果如下图:
在这里插入图片描述
这样,当客户端查询时,也对查询的key做3次hash运算得到3个位置,然后看布隆过滤器中对应位置元素的值是否为1,如果所有对应位置元素的值都为1,就证明key在库中存在,则继续向下查询;如果3个位置中有任意一个位置的值不为1,那么就证明key在库中不存在,直接返回客户端空即可。如下图:

在这里插入图片描述
当客户端查询key4时,key4的3次hash运算中,有一个位置8的值为0,就说明key4在库中不存在,直接返回客户端空即可。

所以,布隆过滤器就相当于一个位于客户端与缓存层中间的拦截器一样,负责判断key是否在集合中存在。如下图:

在这里插入图片描述
布隆过滤器的好处就是解决了第一种缓存空值的不足,但布隆过滤器也存在缺陷,首先,它有误判的可能,比如在上面客户端查询key4的图中,假如key4经过3次hash运算得到的位置分别是2/4/6,由于这3个位置的值都是1,所以,布隆过滤器就认为key4在库中存在,进而继续向下查询了。所以,布隆过滤器判断存在的key实际上可能是不存在的,但布隆过滤器判断不存在的key是一定不存在的。它的第二个缺点就是删除元素比较难,比如现在要删除key2这个元素,那么需要将2/7/11三个位置的元素值改为0,但这样就会影响到key1和key3的判断。

9.2、缓存击穿

缓存击穿是指当缓存中某个热点数据过期了,在该热点数据重新载入缓存之前,有大量的查询请求穿过缓存,直接查询数据库。这种情况会导致数据库压力瞬间骤增,造成大量请求阻塞,甚至直接挂掉。
在这里插入图片描述

9.2.1、缓存击穿解决方案

解决缓存击穿的方法也有两种

9.2.1.1、设置key永不过期

方法比较简单,在设置热点key的时候,不给key设置过期时间即可。不过还有另外一种方式也可以达到key不过期的目的,就是正常给key设置过期时间,不过在后台同时启一个定时任务去定时地更新这个缓存。
在这里插入图片描述

9.2.1.2、使用分布式锁,保证同一时刻只能有一个查询请求重新加载热点数据到缓存中

这样,其他的线程只需等待该线程运行完毕,即可重新从Redis中获取数据。

使用了加锁的方式,锁的对象就是key,这样,当大量查询同一个key的请求并发进来时,只能有一个请求获取到锁,然后获取到锁的线程查询数据库,然后将结果放入到缓存中,然后释放锁,此时,其他处于锁等待的请求即可继续执行,由于此时缓存中已经有了数据,所以直接从缓存中获取到数据返回,并不会查询数据库。

在这里插入图片描述

9.3、缓存雪崩

缓存雪崩是指当缓存中有大量的key在同一时刻过期,或者Redis直接宕机了,导致大量的查询请求全部到达数据库,造成数据库查询压力骤增,甚至直接挂掉。

在这里插入图片描述
针对第一种大量key同时过期的情况,解决起来比较简单,只需要将每个key的过期时间打散即可,使它们的失效点尽可能均匀分布。

针对第二种redis发生故障的情况,部署redis时可以使用redis的几种高可用方案部署,部署方法可以参考我之前的文章redis的多种模式。有主从,哨兵,集群等模式。

除了上面两种解决方式,还可以使用其他策略,比如设置key永不过期、加分布式锁等。

10、高并发场景热点缓存如何重建

10.1、背景

开发人员使用“缓存+过期时间”的策略既可以加速数据读写, 又保证数据的定期更新, 这种模式基本能够满足绝大部分需求。 但是有两个问题如果同时出现, 可能就会对应用造成致命的危害:

  • (1). 当前key是一个热点key(例如一个热门的娱乐新闻),并发量非常大。

  • (2). 重建缓存不能在短时间完成, 可能是一个复杂计算, 例如复杂的SQL、 多次IO、 多个依赖等。

在缓存失效的瞬间, 有大量线程来重建缓存, 造成后端负载加大, 甚至可能会让应用崩溃。

10.2、解决方案

要解决这个问题主要就是要避免大量线程同时重建缓存。

我们可以利用互斥锁来解决,此方法只允许一个线程重建缓存, 其他线程等待重建缓存的线程执行完, 重新从缓存获取数据即可。

代码思路分享:

String get(String key) {
	 // 从Redis中获取数据
	 String value = redis.get(key);
	 // 如果value为空, 则开始重构缓存
	 if (value == null) {
		  // 只允许一个线程重建缓存, 使用nx, 并设置过期时间ex
		  String mutexKey = "mutext:key:" + key;
		  if (redis.set(mutexKey, "1", "ex 180", "nx")) {
			  	// 从数据源获取数据
			    value = db.get(key);
			    // 回写Redis, 并设置过期时间
			    redis.setex(key, timeout, value);
			    // 删除key_mutex
			    redis.delete(mutexKey);
		  }
		  else {
			  	//其它线程休息50ms,重写递归获取
				Thread.sleep(50);
				get(key);
		  }
	}
	return value;
}

11、亿级用户日活统计如何用redis快速计算

11.1、使用string(排除)

如何用合适的数据类型来存储1亿用户的数据,用普通的字符串来存储肯定不行。经过查看一个最简单的kv(key为aaa,value为1)的内存占用,发现为48byte。
假设每个用户每天登陆需要占据1对KV的话,那一亿就是(48*100000000)/1024/1024/1024=4.47G。这还是一天的量。

11.2、bitmap

11.2.1、bitmap介绍

在redis 2.2.0版本之后,新增了一个位图数据,其实它不是一种数据结构。实际上它就是一个一个字符串结构(涉及单个bitmap可存储最大值问题),只不过value是一个二进制数据,每一位只能是0或者1。redis单独对bitmap提供了一套命令。可以对任意一位进行设置和读取。

主要命令有:

  • SETBIT
  • GETBIT
  • BITCOUNT
  • BITPOS
  • BITOP
  • BITFIELD

具体可参考:http://redisdoc.com/bitmap/index.html

相关语法为:

  • SETBIT key offset value
  • GETBIT key offset
  • BITCOUNT key [start] [end]
  • BITOP operation destkey key [key …]

注意事项:

  • bitmap本质为字符串,使用type命令对bitmap的相关key进行操作,可以看出结果为"string",因此最大不能超过512MB,故可存储id号最大为:4294967295=4294967296-1(42亿多),计算方式为512MB可容纳的最大数据:2 = 512* 2 * 2
  • offset为偏移量,对使用大的 offset 的 SETBIT 操作来说,内存分配可能造成 Redis 服务器被阻塞

11.2.2、登录统计方案设计

11.2.2.1、登录标记

用户登录时,使用setbit命令和用户id(假设id=123456)标记当日(2020-10-05)用户已经登录,具体命令如下:

# 时间复杂度O(1)
setbit login:20201005 123456 1
11.2.2.2、每日用户登录数量统计
# 时间复杂度O(N)
bitcount login:20201005
11.2.2.3、活跃用户(连续三日登录)统计

如果我们想要获取近三日活跃用户数量的话,可以使用bitop命令,
bitmap的bitop命令支持对bitmap进行AND(与)(OR)或XOR(亦或)NOT(非)四种相关操作,我们对近三日的bitmap做AND操作即可,操作之后会形成一个新的bitmap,我们可以取名为login:20201005:b3

# 时间复杂度O(N)
bitop and login:20201005:b3 login:20201005 login:20201004 login:20201003

然后我们可以对login:20201005:b3使用bitcount或者getbit命令,用于统计活跃用户数量,或者查看某个用户是否为活跃用户

11.2.2.4、内存占用

我们新建一个bitmap,用于测试最大值4294967296-1,内存相关占用:

127.0.0.1:6379> setbit login:20201005 4294967296 1
(error) ERR bit offset is not an integer or out of range
127.0.0.1:6379> setbit login:20201005 4294967295 1
(integer) 0

我们可以发现直接设置4294967296(超过最大值)会出现报错。

然后退出redis-cli,执行如下命令(测试环境使用),结果如下:

redis-cli -a pdabc  --bigkeys
Warning: Using a password with '-a' or '-u' option on the command line interface may not be safe.

# Scanning the entire keyspace to find biggest keys as well as
# average sizes per key type.  You can use -i 0.1 to sleep 0.1 sec
# per 100 SCAN commands (not usually needed).

[00.00%] Biggest string found so far 'login:20201005' with 536870912 bytes

-------- summary -------

Sampled 1 keys in the keyspace!
Total key length in bytes is 14 (avg len 14.00)

Biggest string found 'login:20201005' has 536870912 bytes

1 strings with 536870912 bytes (100.00% of keys, avg size 536870912.00)
0 lists with 0 items (00.00% of keys, avg size 0.00)
0 hashs with 0 fields (00.00% of keys, avg size 0.00)
0 streams with 0 entries (00.00% of keys, avg size 0.00)
0 sets with 0 members (00.00% of keys, avg size 0.00)
0 zsets with 0 members (00.00% of keys, avg size 0.00)

我们发现我们仅仅设置了一个值,但是这个bitmap已经达到了536870912 bytes = 64MB,如此想想还是有些恐怖的,尤其在初次设置大的offect时会进行内存分配,可能会出现内存分配问题。

期间出现一个有趣的小插曲,当我执行对这个大key进行如下get操作时,直接把redis服务搞崩溃了,因此禁止在生产环境下操作数据还是很有必要的。

[root@root ~]# redis-cli
127.0.0.1:6379> type login:20201005
string
127.0.0.1:6379> get login:20201005
Could not connect to Redis at 127.0.0.1:6379: Connection refused
(3.09s)
not connected> exit
[root@root ~]# ps -ef | grep redis
root     31897 24249  0 16:39 pts/0    00:00:00 grep --color=auto redis

如果key是比较小,正常情况是不会出现这种情况的哦,因此在超大量数据下,使用bitmap还有待商榷,但是在数据量不是很大,但是对统计要求精确的场景,可以使用此方案。

127.0.0.1:6379> setbit login:20201005 123 1
(integer) 1
127.0.0.1:6379> get login:20201005
"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x10"

注意:
BITOP 的复杂度为 O(N) ,当处理大型矩阵(matrix)或者进行大数据量的统计时,最好将任务指派到附属节点(slave)进行,同时最好在服务闲时阶段进行执行,避免阻塞主节点。

11.3、HyperLogLog

redis从2.8.9之后增加了HyperLogLog数据结构。这个数据结构,根据redis的官网介绍,这是一个概率数据结构,用来估算数据的基数。能通过牺牲准确率来减少内存空间的消耗。
HyperLogLog的方法

  • PFADD 添加一个元素,如果重复,只算作一个
  • PFCOUNT 返回元素数量的近似值
  • PFMERGE 将多个 HyperLogLog 合并为一个 HyperLogLog

注意:PFADD命令不支持key中含有":“,可以使用”_"进行分割

127.0.0.1:6379> PFADD login:20201005 123456
(error) WRONGTYPE Key is not a valid HyperLogLog string value.
127.0.0.1:6379> PFADD login_20201005 123456
(integer) 1

通过测试工程往HyperLogLog里PFADD了一亿个元素。通过rdb tools工具统计了这个key的信息
只需要14392 Bytes!也就是14KB的空间。对,你没看错。就是14K。bitmap存储一亿需要12M,而HyperLogLog只需要14K的空间。
查了文档,发现HyperLogLog是一种概率性数据结构,在标准误差0.81%的前提下,能够统计2^64个数据。所以 HyperLogLog 适合在比如统计日活月活此类的对精度要不不高的场景。

11.4、bitmap与HyperLogLog对比

11.4.1、bitmap

11.4.1.1、优点

非常均衡的特性,精准统计,可以得到每个统计对象的状态,秒出。

11.4.1.2、缺点

当你的统计对象数量十分十分巨大时,可能会占用到一点存储空间,但也可在接受范围内。也可以通过分片,或者压缩的额外手段去解决。

11.4.2、HyperLogLog

11.4.2.1、优点

可以统计夸张到无法想象的数量,并且占用小的夸张的内存。

11.4.2.2、缺点

建立在牺牲准确率的基础上,而且无法得到每个统计对象的状态。

12、redis底层zset跳表是如何设计与实现的

redis的有序集合zset在增删改查的性质上类似于C++ stl的map和Java的TreeMap,提供了一组“键-值”对,并且“键”按照“值”的顺序排序。但是与C++ stl或Java的红黑树实现不同的是,redis中有序集合的实现采用了另一种数据结构——跳跃表。跳跃表是有序单链表的一种改进,其查询、插入、删除也是O(logN)的时间复杂度。

zset的两种实现方式

  • ziplist(压缩链表):满足以下两个条件的时候
    • 元素数量少于128的时候
    • 每个元素的长度小于64字节
  • skiplist(跳跃链表):不满足上述两个条件就会使用跳表,具体来说是组合了map和skiplist
    • map用来存储member到score的映射,这样就可以在O(1)时间内找到member对应的分数
    • skiplist按从小到大的顺序存储分数
    • skiplist每个元素的值都是[score,value]对

ziplist原理
每个集合元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员,第二个元素保存元素的分值。压缩列表中的元素按照分数从小到大依次紧挨着排列,有效减少了内存空间的使用。
在这里插入图片描述

12.1、跳表详解

跳表是一个随机化的数据结构,实质就是一种可以进行二分查找的有序链表。

跳表在原有的有序链表上面增加了多级索引,通过索引来实现快速查找。

跳表不仅能提高搜索性能,同时也可以提高插入和删除操作的性能。
有序列表
在这里插入图片描述

考虑一个有序链表,我们要查找3、7、17这几个元素,我们只能从头开始遍历链表,直到查找到元素为止。

上述这个链表是有序的,但是不能使用二分查找,是不是很捉急?(P.S.数组可以实现二分查找)

那么,有没有什么方法可以实现有序链表的二分查找呢?

答案是肯定的,那就是我们将要介绍的这种数据结构——跳表。

12.1.1、跳表的演进

我们把一些节点从有序表中提取出来,缓存一级索引,就组成了下面这样的结构:
在这里插入图片描述
现在,我们要查找17这个元素是不是要快很多呢?

我们只要从一级索引往后遍历即可,只需要经过1、6、15、17这几个元素就可以找到17了。

那么,我们要查找11这个元素呢?

我们从一级索引的1开始,向右到6,再向右发现是15,它比11大,此路不通,从6往下走,再从下面的6往右走,到7,再到11。

同样地,一级索引也可以往上再提取一层,组成二级索引,如下:

在这里插入图片描述
这时候我们再查找17这个元素呢?

只需要经过6、15、17这几个元素就可以找到17了。

这基本上就是跳表的核心思想了,其实这也是一个“空间换时间”的算法,通过向上提取索引增加了查找的效率。

12.1.2、跳表的插入

上面讲的都是跳表的查询,那么,该如何向跳表中插入元素呢?

比如,我们要向上面这个跳表添加一个元素8。

首先,我们先根据投硬币的方式,决定8这个元素要占据的层数,没错就是扔硬币,是不是很好玩儿^^

比如,层数level=2。

然后,找到8这个元素在下面两层的前置节点。

接着,就是链表的插入元素操作了,比较简单。

最后,就像下面这样:
在这里插入图片描述

12.1.3、跳表的删除

查询、插入元素都讲了,下面我们就来说说怎么删除元素。

首先,找到各层中包含元素x的节点。

然后,使用标准的链表删除元素的方法删除即可。

比如,要删除17这个元素。
在这里插入图片描述

12.2、标准化的跳表

上面举的例子是完全随机的跳表,那么,如果我们每两个元素提取一个元素作为上一级的索引会怎么样呢?
在这里插入图片描述
这是不是很像*衡二叉树,现在这颗树元素比较少,可能不太明显,我们来看个元素个数多的情况。
在这里插入图片描述
可以看到,上一级元素的个数是下一级的一半,这样每次减少一半,就很接**衡二叉树了。

12.3、时间复杂度

我们知道单链表查询的时间复杂度为O(n),而插入、删除操作需要先找到对应的位置,所以插入、删除的时间复杂度也是O(n)。

那么,跳表的时间复杂度是多少呢?

如果按照标准的跳表来看的话,每一级索引减少k/2个元素(k为其下面一级索引的个数),那么整个跳表的高度就是(log n)。

学习过*衡二叉树的同学都知道,它的时间复杂度与树的高度成正比,即O(log n)。

所以,这里跳表的时间复杂度也是O(log n)。(这里不一步步推倒了,只要记住,查询时每次减少一半的元素的时间复杂度都是O(log n),比如二叉树的查找、二分法查找、归并排序、快速排序)

12.4、空间复杂度

我们还是以标准的跳表来分析,每两个元素向上提取一个元素,那么,最后额外需要的空间就是:

n/2 + n/(2^2) + n/(2^3) + … + 8 + 4 + 2 = n - 2

所以,跳表的空间复杂度是O(n)。

12.5、跳表总结

  • (1)跳表是可以实现二分查找的有序链表;

  • (2)每个元素插入时随机生成它的level;

  • (3)最低层包含所有的元素;

  • (4)如果一个元素出现在level(x),那么它肯定出现在x以下的level中;

  • (5)每个索引节点包含两个指针,一个向下,一个向右;

  • (6)跳表查询、插入、删除的时间复杂度为O(log n),与衡二叉树接

redis作为一种内存KV数据库,提供了string, hash, list, set, zset等多种数据结构。其中有序集合zset在增删改查的性质上类似于C++ stl的map和Java的TreeMap,提供了一组“键-值”对,并且“键”按照“值”的顺序排序。但是与C++ stl或Java的红黑树实现不同的是,redis中有序集合的实现采用了另一种数据结构——跳跃表。跳跃表是有序单链表的一种改进,其查询、插入、删除也是O(logN)的时间复杂度。

12.6、跳跃表的原理

跳跃表的思想来自于一篇论文:Skip Lists: A Probabilistic Alternative to Balanced Trees 如果想要深入了解跳跃表,可以阅读论文原文。这里引用论文中的一幅图对跳跃表的原理作一个简单的说明。

在这里插入图片描述
上图用a,b,c,d,e五种有序链表及其变式说明了跳跃表的motivation.

  • [a]单链表:查询时间复杂度O(n)
  • [b]level-2单链表:每隔一个节点为一个level-2节点,每个level-2节点有2个后继指针,分别指向单链表中的下一个节点和下一个level-2节点。查询时间复杂度为O(n/2)
  • [c]level-3单链表:每隔一个节点为一个level-2节点,每隔4个节点为一个level-3节点,查询时间复杂度O(n/4)
  • [d]指数式单链表:每2^i个节点的level为i+1,查询时间复杂度为O(log2N)
  • [e]跳跃表:各个level的节点个数同指数式单链表,但出现的位置随机,查询复杂度仍然是O(log2N)

之所以这里关心查询复杂度,因为有序链表的插入和删除复杂度等于查询复杂度。

作为一种概率性算法,文章证明了跳跃表查询复杂度的期望是O(logN).

12.7、redis有序集合采用跳跃表的原因

为什么redis的有序集合采用跳跃表而不是红黑树呢?对于这个问题,可以在https://news.ycombinator.com/item?id=1171423找到作者本人的一个回答

1.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.
2.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.
3.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.

About the Append Only durability & speed, I don’t think it is a good idea to optimize Redis at cost of more code and more complexity for a use case that IMHO should be rare for the Redis target (fsync() at every command). Almost no one is using this feature even with ACID SQL databases, as the performance hint is big anyway.

About threads: our experience shows that Redis is mostly I/O bound. I’m using threads to serve things from Virtual Memory. The long term solution to exploit all the cores, assuming your link is so fast that you can saturate a single core, is running multiple instances of Redis (no locks, almost fully scalable linearly with number of cores), and using the “Redis Cluster” solution that I plan to develop in the future.

可以看到redis选择跳跃表而非红黑树作为有序集合实现方式的原因并非是基于并发上的考虑,因为redis是单线程的,选用跳跃表的原因仅仅是因为跳跃表的实现相较于红黑树更加简洁。

12.8、redis跳跃表的源码

跳跃表节点,跳跃表和zset结构体的定义在server.h

/* ZSETs use a specialized version of Skiplists */
typedef struct zskiplistNode {
    sds ele;
    double score;
    struct zskiplistNode *backward;
    struct zskiplistLevel {
        struct zskiplistNode *forward;
        unsigned long span;
    } level[];
} zskiplistNode;
 
typedef struct zskiplist {
    struct zskiplistNode *header, *tail;
    unsigned long length;
    int level;
} zskiplist;
 
typedef struct zset {
    dict *dict;
    zskiplist *zsl;
} zset;

skiplist相关函数定义在t_zset.c中。跳跃表中,一个节点的level符合一定的概率,决定一个新增节点的level的函数是zslRandomLevel

/* Returns a random level for the new skiplist node we are going to create.
 * The return value of this function is between 1 and ZSKIPLIST_MAXLEVEL
 * (both inclusive), with a powerlaw-alike distribution where higher
 * levels are less likely to be returned. */
int zslRandomLevel(void) {
    int level = 1;
    while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
        level += 1;
    return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}

12.9、总结

12.9.1、为什么Redis选择使用跳表而不是红黑树来实现有序集合?

首先,我们来分析下Redis的有序集合支持的操作:

  • 1)插入元素

  • 2)删除元素

  • 3)查找元素

  • 4)有序输出所有元素

  • 5)查找区间内所有元素

其中,前4项红黑树都可以完成,且时间复杂度与跳表一致。

但是,最后一项,红黑树的效率就没有跳表高了。

在跳表中,要查找区间的元素,我们只要定位到两个区间端点在最低层级的位置,然后按顺序遍历元素就可以了,非常高效。

而红黑树只能定位到端点后,再从首位置开始每次都要查找后继节点,相对来说是比较耗时的。

此外,跳表实现起来很容易且易读,红黑树实现起来相对困难,所以Redis选择使用跳表来实现有序集合。

12.9.2、为什么采用跳表,而不使用哈希表或平衡树实现呢?

  • 1、skiplist和各种平衡树(如AVL、红黑树等)的元素是有序排列的,而哈希表不是有序的。因此,在哈希表上只能做单个key的查找,不适宜做范围查找。所谓范围查找,指的是查找那些大小在指定的两个值之间的所有节点。
  • 2、在做范围查找的时候,平衡树比skiplist操作要复杂。在平衡树上,我们找到指定范围的小值之后,还需要以中序遍历的顺序继续寻找其它不超过大值的节点。如果不对平衡树进行一定的改造,这里的中序遍历并不容易实现。而在skiplist上进行范围查找就非常简单,只需要在找到小值之后,对第1层链表进行若干步的遍历就可以实现。
  • 3、平衡树的插入和删除操作可能引发子树的调整,逻辑复杂,而skiplist的插入和删除只需要修改相邻节点的指针,操作简单又快速。
  • 4、从内存占用上来说,skiplist比平衡树更灵活一些。一般来说,平衡树每个节点包含2个指针(分别指向左右子树),而skiplist每个节点包含的指针数目平均为1/(1-p),具体取决于参数p的大小。如果像Redis里的实现一样,取p=1/4,那么平均每个节点包含1.33个指针,比平衡树更有优势。

12.9.3、Redis的压缩列表和跳跃表

Redis的配置文件中关于有序集合底层实现的两个配置。

  • 1、zset-max-ziplist-entries 128:zset采用压缩列表时,元素个数最大值。默认值为128。
  • 2、zset-max-ziplist-value 64:zset采用压缩列表时,每个元素的字符串长度最大值。默认值为64。

当满足以下任一条件时,Redis便会将zset的底层实现由压缩列表转为跳跃表。

  • 1、zset中元素个数大于zset_max_ziplist_entries;
  • 2、插入元素的字符串长度大于zset_max_ziplist_value。

zset在转为跳跃表之后,即使元素被逐渐删除,也不会重新转为压缩列表。

压缩列表ziplist本质上就是一个字节数组,是Redis为了节约内存而设计的一种线性数据结构,可以包含多个元素,每个元素可以是一个字节数组或一个整数。

因为ziplist是紧凑存储,没有冗余空间,意味着新插入元素,就需要扩展内存:

  • 1、分配新的内存,将原数据拷贝到新内存;
  • 2、扩展原有内存;

所以ziplist 不适合存储大型字符串,存储的元素也不宜过多。

13、类似微信的社交App朋友关注模型如何设计实现

现在很多社交都有关注或者添加粉丝的功能, 类似于这样的功能我们如果采用数据库做的话只是单纯得到用户的一些粉丝或者关注列表的话是很简单也很容易实现, 但是如果我想要查出两个甚至多个用户共同关注了哪些人或者想要查询两个或者多个用户的共同粉丝的话就会很麻烦, 效率也不会很高. 但是如果你用redis去做的话就会相当的简单而且效率很高. 原因是redis自己本身带有专门针对于这种集合的交集,并集, 差集的一些操作。

13.1、设计思路

我们采用redis里面的zset完成整个功能, 原因是zset有排序(我们要按照关注时间的倒序排列), 去重(我们不能多次关注同一用户)功能. 一个用户我们存贮两个集合, 一个是保存用户关注的人 另一个是保存关注用户的人.
用到的命令是:

  • 1、 zadd 添加成员:命令格式: zadd key score member [score …]
  • 2、zrem 移除某个成员:命令格式: zrem key member [member …]
  • 3、 zcard 统计集合内的成员数:命令格式: zcard key
  • 4、 zrange 查询集合内的成员:命令格式: ZRANGE key start stop [WITHSCORES]
    描述:返回指定区间的成员。其中成员位置按 score 值递增(从小到大)来排序。 WITHSCORES选项是用来让成员和它的score值一并返回.
  • 5、 zrevrange跟zrange作用相反
  • 6、zrank获取成员的排名:命令格式: zrank key member
    ​描述:返回有序集key中成员member的排名。成员按 score 值递增(从小到大)顺序排列。排名以0开始,也就是说score 值最小的为0。返回值:返回成员排名,member不存在返回nil.
  • 7、 zinterstore 取两个集合的交集:命令格式:ZINTERSTORE destination numkeys key key …] [AGGREGATE SUM|MIN|MAX]
    ​描述:计算给定的一个或多个有序集的交集。其中给定 key 的数量必须以 numkeys 参数指定,并将该交集(结果集)储存到 destination 。默认情况下,结果集中某个成员的 score 值是所有给定集下该成员 score 值之和。
    返回值:保存到 destination 的结果集成员数。

13.2、代码实现

13.2.1、添加Redis客户端

<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>2.9.0</version>
</dependency>

13.2.2、封装redis工具类

    import redis.clients.jedis.Jedis;
    import redis.clients.jedis.JedisPool;
    import redis.clients.jedis.JedisPoolConfig;
    public final class RedisUtil {
        //Redis服务器IP
        private static String ADDR = "localhost";
        //Redis的端口号
        private static int PORT = 6379;
        //访问密码
        private static String AUTH = "admin";
        //控制一个pool最多有多少个状态为idle(空闲的)的jedis实例,默认值也是8。
        private static int MAX_IDLE = 200;
        private static int TIMEOUT = 10000;
        //在borrow一个jedis实例时,是否提前进行validate操作;如果为true,则得到的jedis实例均是可用的;
        private static boolean TEST_ON_BORROW = true;
        private static JedisPool jedisPool = null;
        
        static {
            try {
                JedisPoolConfig config = new JedisPoolConfig();
                config.setMaxIdle(MAX_IDLE);
                config.setTestOnBorrow(TEST_ON_BORROW);
                jedisPool = new JedisPool(config, ADDR, PORT, TIMEOUT, AUTH);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        
        public synchronized static Jedis getJedis() {
            try {
                if (jedisPool != null) {
                    Jedis resource = jedisPool.getResource();
                    return resource;
                } else {
                    return null;
                }
            } catch (Exception e) {
                e.printStackTrace();
                return null;
            }
        }
       
        @SuppressWarnings("deprecation")
        public static void returnResource(final Jedis jedis) {
            if (jedis != null) {
                jedisPool.returnResource(jedis);
            }
        }
    }

13.2.3、封装follow工具类

    import java.util.HashSet;
    import java.util.Set;
    
    import redis.clients.jedis.Jedis;
    
    import com.indulgesmart.base.util.RedisUtil;
    public class FollowUtil {
    
        private static final String FOLLOWING = "FOLLOWING_";
        private static final String FANS = "FANS_";
        private static final String COMMON_KEY = "COMMON_FOLLOWING";
    
        // 关注或者取消关注
        public static int addOrRelease(String userId, String followingId) {
            if (userId == null || followingId == null) {
                return -1;
            }
            int isFollow = 0; // 0 = 取消关注 1 = 关注
            Jedis jedis = RedisUtil.getJedis();
            String followingKey = FOLLOWING + userId;
            String fansKey = FANS + followingId;
            if (jedis.zrank(followingKey, followingId) == null) { // 说明userId没有关注过followingId
                jedis.zadd(followingKey, System.currentTimeMillis(), followingId);
                jedis.zadd(fansKey, System.currentTimeMillis(), userId);
                isFollow = 1;
            } else { // 取消关注
                jedis.zrem(followingKey, followingId);
                jedis.zrem(fansKey, fansKey);
            }
            return isFollow;
        }
    
        
         // 验证两个用户之间的关系 
         // 0=没关系  1=自己 2=userId关注了otherUserId 3= otherUserId是userId的粉丝 4=互相关注
        public int checkRelations (String userId, String otherUserId) {
    
            if (userId == null || otherUserId == null) {
                return 0;
            }
            
            if (userId.equals(otherUserId)) {
                return 1;
            }
            Jedis jedis = RedisUtil.getJedis();
            String followingKey = FOLLOWING + userId;
            int relation = 0;
            if (jedis.zrank(followingKey, otherUserId) != null) { // userId是否关注otherUserId
                relation = 2;
            }
            String fansKey = FANS + userId;
            if (jedis.zrank(fansKey, userId) != null) {// userId粉丝列表中是否有otherUserId
                relation = 3;
            }
            if ((jedis.zrank(followingKey, otherUserId) != null) 
                    && jedis.zrank(fansKey, userId) != null) {
                relation = 4;
            }
            return relation;
        }
    
        // 获取用户所有关注的人的id
        public static Set<String> findFollwings(String userId) {
            return findSet(FOLLOWING + userId);
        }
    
        // 获取用户所有的粉丝
        public static Set<String> findFans(String userId) {
            return findSet(FANS + userId);
        }
        
        // 获取两个共同关注的人
        public static Set<String> findCommonFollowing(String userId, String otherUserId) {
            if (userId == null || otherUserId == null) {
                return new HashSet<>();
            }
            Jedis jedis = RedisUtil.getJedis();
            String commonKey = COMMON_KEY + userId + "_" + otherUserId;
            // 取交集
            jedis.zinterstore(commonKey + userId + "_" + otherUserId, FOLLOWING + userId, FOLLOWING + otherUserId); 
            Set<String> result = jedis.zrange(commonKey, 0, -1);
            jedis.del(commonKey);
            return result;
        }
        
        // 根据key获取set
        private static Set<String> findSet(String key) {
            if (key == null) {
                return new HashSet<>();
            }
            Jedis jedis = RedisUtil.getJedis();
            Set<String> result = jedis.zrevrange(key, 0, -1); // 按照score从大到小排序
            return result;
        }
    }

14、如何设计高性能电商推荐系统

这个问题,可以参考阿里大佬的博客:大数据项目实战——电商推荐系统设计

15、Rocketmq中的顺序消息、延迟消息怎么做到的?

16、大型电商商品详细页如何用CompletableFuture异步加载实现?

通产在商品详情页需要根据商品id调用其他的服务,获取商品的其他信息,这时可以使用CompletableFuture来调用其他的服务,进行调用,最后把结果合起来,返回结果。例如如下:

 
//1.获取商品的a信息
CompletableFuture<Object> aFuture = CompletableFuture.supplyAsync(() -> **);
 
//2.获取商品的b信息
CompletableFuture<Object> bFuture = CompletableFuture.supplyAsync(() -> **);
 
//3.获取商品的c信息
CompletableFuture<Object> cFuture = CompletableFuture.supplyAsync(() -> **);
 
 
CompletableFuture.allOf(aFuture, bFuture, cFuture).join();

PS:使用CompletableFuture异步编程在java代码中实现异步操作

17、如何利用并发中ThreadLocal在高并发下做用户身份鉴别?

18、高并发下如何利用信号量Semaphore做流量限流?

在java开发的工作中会出现这样的场景,需要实现一些异步运行的任务,该任务可能存在消耗大量内存的情况,所以需要对任务进行并发控制。如何优雅的实现并发控制呢?Semaphore类能很优雅的实现并发控制

18.1、Semaphore介绍

首先我们看一下Semaphore类的构造函数是如何实现的。

    public Semaphore(int permits, boolean fair) {
        sync = fair ? new FairSync(permits) : new NonfairSync(permits);
    }

我们可以看到有两个参数,分别对应的是信号量的许可次数以及是否为公平锁

我们看一下主要使用到的方法——tryAcquire,尝试申请锁,看一下该方法有几个实现。

	public boolean tryAcquire() {
        return sync.nonfairTryAcquireShared(1) >= 0;
    }
    public boolean tryAcquire(long timeout, TimeUnit unit)
        throws InterruptedException {
        return sync.tryAcquireSharedNanos(1, unit.toNanos(timeout));
    }
    public boolean tryAcquire(int permits) {
        if (permits < 0) throw new IllegalArgumentException();
        return sync.nonfairTryAcquireShared(permits) >= 0;
    }
    public boolean tryAcquire(int permits, long timeout, TimeUnit unit)
        throws InterruptedException {
        if (permits < 0) throw new IllegalArgumentException();
        return sync.tryAcquireSharedNanos(permits, unit.toNanos(timeout));
    }

我们需要注意的是,Semaphore是一个可重入的共享锁,所以除了可以增加申请锁的超时时间外,还可以设置申请的许可证数量。一般在业务场景中申请1次就可以了。

Semaphore还提供了阻塞的申请方式,一旦执行就会一直阻塞直到申请到锁,就是acquire方法。有兴趣的话,可以看看,也基本上支持多种参数实现。

18.2、代码实现

18.2.1、添加依赖

		 <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>5.7.15</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

18.2.2、代码

package com.huyi.csdn.tools;

import cn.hutool.core.thread.ThreadUtil;
import lombok.Builder;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.concurrent.CustomizableThreadFactory;

import java.util.Random;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.IntStream;

@Slf4j
public class SemaphoreDemo {
  private static final Semaphore SEMAPHORE = new Semaphore(3, true);
  private static final ExecutorService POOL =
      Executors.newFixedThreadPool(10, new CustomizableThreadFactory("TASK-"));
  private static final ScheduledExecutorService ENGINE_POOL =
      Executors.newSingleThreadScheduledExecutor(new CustomizableThreadFactory("ENGINE-"));
  public static LinkedBlockingQueue<Task> taskQueue;
  public static AtomicInteger codeBuilder;

  @Data
  @Builder
  public static class Task {
    private Integer code;
    private Runnable work;
  }

  public static void init() {
    taskQueue = new LinkedBlockingQueue<>();
    codeBuilder = new AtomicInteger(0);
    log.info(">>> 任务队列初始化");
    log.info(">>> 任务引擎启动");
    engineOn();
  }

  private static void engineOn() {
    ENGINE_POOL.scheduleAtFixedRate(
        () -> {
          if (taskQueue.isEmpty()) {
            log.info("队列为空,无任务需要执行");
          } else {
            if (SEMAPHORE.tryAcquire()) {
              try {
                Task task = taskQueue.poll();
                log.info("code:{} 任务获得执行许可", task.getCode());
                POOL.submit(task.getWork());
                log.info("code:{} 任务提交执行", task.getCode());
              } catch (Exception exception) {
                exception.printStackTrace();
              }
            } else {
              log.info("执行任务数量已经达到限制,无法执行任务");
            }
          }
        },
        0,
        1,
        TimeUnit.SECONDS);
  }

  public static void addTask(Runnable runnable) {
    Integer code = codeBuilder.incrementAndGet();
    Task task =
        Task.builder()
            .code(code)
            .work(
                () -> {
                  try {
                    runnable.run();
                  } catch (Exception exception) {
                    exception.printStackTrace();
                  } finally {
                    log.info("code:{}-结束任务", code);
                    SEMAPHORE.release();
                  }
                })
            .build();
    taskQueue.add(task);
  }

  public static void main(String[] args) {
    SemaphoreDemo.init();
    Random random = new Random();
    for (int i = 0; i < 10; i++) {
      SemaphoreDemo.addTask(
          () -> {
            IntStream.range(1, random.nextInt(10) + 1)
                .forEach(
                    a -> {
                      log.info("第{}次进攻敌方基地", a);
                      ThreadUtil.sleep(1000);
                    });
            log.info("进攻结束");
          });
    }
  }
}

代码说明:

  • 1、先添加了两个静态的线程池,一个为给引擎工作的定时线程池,一个为给异步任务提供的任务线程池。

  • 2、在初始化的时候会提前创建好任务队列,这里使用的是LinkedBlockingQueue。

  • 3、初始化之后,可以引擎会定时尝试获取Semaphore的许可证,如果可以拿到则将任务提交给线程池执行。

  • 4、在构建任务的时候,会将需要执行的内容重新包装,保证任务执行结束的时候会主动释放Semaphore的许可证。

  • 5、main方法主要是在10秒内提交10个不定时完成的任务,我们可以看看是否限制了异步任务的数量。

18.2.3、代码运行结果

17:23:20.516 [main] INFO com.huyi.csdn.tools.SemaphoreDemo - >>> 任务队列初始化
17:23:20.519 [main] INFO com.huyi.csdn.tools.SemaphoreDemo - >>> 任务引擎启动
17:23:20.559 [ENGINE-1] INFO com.huyi.csdn.tools.SemaphoreDemo - 队列为空,无任务需要执行
17:23:21.572 [ENGINE-1] INFO com.huyi.csdn.tools.SemaphoreDemo - code:1 任务获得执行许可
17:23:21.575 [ENGINE-1] INFO com.huyi.csdn.tools.SemaphoreDemo - code:1 任务提交执行
17:23:21.577 [TASK-1] INFO com.huyi.csdn.tools.SemaphoreDemo - 第1次进攻敌方基地
17:23:22.568 [ENGINE-1] INFO com.huyi.csdn.tools.SemaphoreDemo - code:2 任务获得执行许可
17:23:22.568 [ENGINE-1] INFO com.huyi.csdn.tools.SemaphoreDemo - code:2 任务提交执行
17:23:22.568 [TASK-2] INFO com.huyi.csdn.tools.SemaphoreDemo - 第1次进攻敌方基地
17:23:22.584 [TASK-1] INFO com.huyi.csdn.tools.SemaphoreDemo - 第2次进攻敌方基地
17:23:23.561 [ENGINE-1] INFO com.huyi.csdn.tools.SemaphoreDemo - code:3 任务获得执行许可
17:23:23.561 [ENGINE-1] INFO com.huyi.csdn.tools.SemaphoreDemo - code:3 任务提交执行
17:23:23.561 [TASK-3] INFO com.huyi.csdn.tools.SemaphoreDemo - 第1次进攻敌方基地
17:23:23.576 [TASK-2] INFO com.huyi.csdn.tools.SemaphoreDemo - 第2次进攻敌方基地
17:23:23.592 [TASK-1] INFO com.huyi.csdn.tools.SemaphoreDemo - 第3次进攻敌方基地
17:23:24.571 [TASK-3] INFO com.huyi.csdn.tools.SemaphoreDemo - 第2次进攻敌方基地
17:23:24.571 [ENGINE-1] INFO com.huyi.csdn.tools.SemaphoreDemo - 执行任务数量已经达到限制,无法执行任务
17:23:24.587 [TASK-2] INFO com.huyi.csdn.tools.SemaphoreDemo - 第3次进攻敌方基地
17:23:24.602 [TASK-1] INFO com.huyi.csdn.tools.SemaphoreDemo - 第4次进攻敌方基地
17:23:25.565 [ENGINE-1] INFO com.huyi.csdn.tools.SemaphoreDemo - 执行任务数量已经达到限制,无法执行任务
17:23:25.581 [TASK-3] INFO com.huyi.csdn.tools.SemaphoreDemo - 第3次进攻敌方基地
17:23:25.596 [TASK-2] INFO com.huyi.csdn.tools.SemaphoreDemo - 第4次进攻敌方基地
17:23:25.611 [TASK-1] INFO com.huyi.csdn.tools.SemaphoreDemo - 第5次进攻敌方基地
17:23:26.570 [ENGINE-1] INFO com.huyi.csdn.tools.SemaphoreDemo - 执行任务数量已经达到限制,无法执行任务
17:23:26.584 [TASK-3] INFO com.huyi.csdn.tools.SemaphoreDemo - 第4次进攻敌方基地
17:23:26.600 [TASK-2] INFO com.huyi.csdn.tools.SemaphoreDemo - 第5次进攻敌方基地
17:23:26.615 [TASK-1] INFO com.huyi.csdn.tools.SemaphoreDemo - 进攻结束
17:23:26.615 [TASK-1] INFO com.huyi.csdn.tools.SemaphoreDemo - code:1-结束任务
17:23:27.573 [ENGINE-1] INFO com.huyi.csdn.tools.SemaphoreDemo - code:4 任务获得执行许可
17:23:27.573 [ENGINE-1] INFO com.huyi.csdn.tools.SemaphoreDemo - code:4 任务提交执行
17:23:27.574 [TASK-4] INFO com.huyi.csdn.tools.SemaphoreDemo - 进攻结束
17:23:27.574 [TASK-4] INFO com.huyi.csdn.tools.SemaphoreDemo - code:4-结束任务
17:23:27.589 [TASK-3] INFO com.huyi.csdn.tools.SemaphoreDemo - 进攻结束
17:23:27.589 [TASK-3] INFO com.huyi.csdn.tools.SemaphoreDemo - code:3-结束任务
17:23:27.605 [TASK-2] INFO com.huyi.csdn.tools.SemaphoreDemo - 第6次进攻敌方基地
17:23:28.571 [ENGINE-1] INFO com.huyi.csdn.tools.SemaphoreDemo - code:5 任务获得执行许可
17:23:28.571 [ENGINE-1] INFO com.huyi.csdn.tools.SemaphoreDemo - code:5 任务提交执行
17:23:28.571 [TASK-5] INFO com.huyi.csdn.tools.SemaphoreDemo - 第1次进攻敌方基地
17:23:28.618 [TASK-2] INFO com.huyi.csdn.tools.SemaphoreDemo - 第7次进攻敌方基地
17:23:29.565 [ENGINE-1] INFO com.huyi.csdn.tools.SemaphoreDemo - code:6 任务获得执行许可
17:23:29.565 [ENGINE-1] INFO com.huyi.csdn.tools.SemaphoreDemo - code:6 任务提交执行
17:23:29.565 [TASK-6] INFO com.huyi.csdn.tools.SemaphoreDemo - 第1次进攻敌方基地
17:23:29.581 [TASK-5] INFO com.huyi.csdn.tools.SemaphoreDemo - 第2次进攻敌方基地
17:23:29.628 [TASK-2] INFO com.huyi.csdn.tools.SemaphoreDemo - 进攻结束
17:23:29.628 [TASK-2] INFO com.huyi.csdn.tools.SemaphoreDemo - code:2-结束任务
17:23:30.571 [TASK-6] INFO com.huyi.csdn.tools.SemaphoreDemo - 第2次进攻敌方基地
17:23:30.571 [ENGINE-1] INFO com.huyi.csdn.tools.SemaphoreDemo - code:7 任务获得执行许可
17:23:30.571 [ENGINE-1] INFO com.huyi.csdn.tools.SemaphoreDemo - code:7 任务提交执行
17:23:30.571 [TASK-7] INFO com.huyi.csdn.tools.SemaphoreDemo - 第1次进攻敌方基地
17:23:30.586 [TASK-5] INFO com.huyi.csdn.tools.SemaphoreDemo - 进攻结束
17:23:30.586 [TASK-5] INFO com.huyi.csdn.tools.SemaphoreDemo - code:5-结束任务
17:23:31.561 [ENGINE-1] INFO com.huyi.csdn.tools.SemaphoreDemo - code:8 任务获得执行许可
17:23:31.561 [ENGINE-1] INFO com.huyi.csdn.tools.SemaphoreDemo - code:8 任务提交执行
17:23:31.561 [TASK-8] INFO com.huyi.csdn.tools.SemaphoreDemo - 第1次进攻敌方基地
17:23:31.577 [TASK-6] INFO com.huyi.csdn.tools.SemaphoreDemo - 第3次进攻敌方基地
17:23:31.577 [TASK-7] INFO com.huyi.csdn.tools.SemaphoreDemo - 第2次进攻敌方基地
17:23:32.572 [ENGINE-1] INFO com.huyi.csdn.tools.SemaphoreDemo - 执行任务数量已经达到限制,无法执行任务
17:23:32.572 [TASK-8] INFO com.huyi.csdn.tools.SemaphoreDemo - 第2次进攻敌方基地
17:23:32.587 [TASK-7] INFO com.huyi.csdn.tools.SemaphoreDemo - 第3次进攻敌方基地
17:23:32.587 [TASK-6] INFO com.huyi.csdn.tools.SemaphoreDemo - 第4次进攻敌方基地
17:23:33.564 [ENGINE-1] INFO com.huyi.csdn.tools.SemaphoreDemo - 执行任务数量已经达到限制,无法执行任务
17:23:33.581 [TASK-8] INFO com.huyi.csdn.tools.SemaphoreDemo - 第3次进攻敌方基地
17:23:33.596 [TASK-7] INFO com.huyi.csdn.tools.SemaphoreDemo - 第4次进攻敌方基地
17:23:33.596 [TASK-6] INFO com.huyi.csdn.tools.SemaphoreDemo - 第5次进攻敌方基地
17:23:34.561 [ENGINE-1] INFO com.huyi.csdn.tools.SemaphoreDemo - 执行任务数量已经达到限制,无法执行任务
17:23:34.592 [TASK-8] INFO com.huyi.csdn.tools.SemaphoreDemo - 第4次进攻敌方基地
17:23:34.607 [TASK-7] INFO com.huyi.csdn.tools.SemaphoreDemo - 第5次进攻敌方基地
17:23:34.607 [TASK-6] INFO com.huyi.csdn.tools.SemaphoreDemo - 进攻结束
17:23:34.607 [TASK-6] INFO com.huyi.csdn.tools.SemaphoreDemo - code:6-结束任务
17:23:35.571 [ENGINE-1] INFO com.huyi.csdn.tools.SemaphoreDemo - code:9 任务获得执行许可
17:23:35.571 [ENGINE-1] INFO com.huyi.csdn.tools.SemaphoreDemo - code:9 任务提交执行
17:23:35.571 [TASK-9] INFO com.huyi.csdn.tools.SemaphoreDemo - 第1次进攻敌方基地
17:23:35.602 [TASK-8] INFO com.huyi.csdn.tools.SemaphoreDemo - 进攻结束
17:23:35.602 [TASK-8] INFO com.huyi.csdn.tools.SemaphoreDemo - code:8-结束任务
17:23:35.617 [TASK-7] INFO com.huyi.csdn.tools.SemaphoreDemo - 第6次进攻敌方基地
17:23:36.571 [ENGINE-1] INFO com.huyi.csdn.tools.SemaphoreDemo - code:10 任务获得执行许可
17:23:36.571 [TASK-9] INFO com.huyi.csdn.tools.SemaphoreDemo - 进攻结束
17:23:36.571 [TASK-9] INFO com.huyi.csdn.tools.SemaphoreDemo - code:9-结束任务
17:23:36.571 [ENGINE-1] INFO com.huyi.csdn.tools.SemaphoreDemo - code:10 任务提交执行
17:23:36.571 [TASK-10] INFO com.huyi.csdn.tools.SemaphoreDemo - 第1次进攻敌方基地
17:23:36.617 [TASK-7] INFO com.huyi.csdn.tools.SemaphoreDemo - 第7次进攻敌方基地
17:23:37.564 [ENGINE-1] INFO com.huyi.csdn.tools.SemaphoreDemo - 队列为空,无任务需要执行
17:23:37.579 [TASK-10] INFO com.huyi.csdn.tools.SemaphoreDemo - 进攻结束
17:23:37.579 [TASK-10] INFO com.huyi.csdn.tools.SemaphoreDemo - code:10-结束任务
17:23:37.626 [TASK-7] INFO com.huyi.csdn.tools.SemaphoreDemo - 第8次进攻敌方基地
17:23:38.568 [ENGINE-1] INFO com.huyi.csdn.tools.SemaphoreDemo - 队列为空,无任务需要执行
17:23:38.630 [TASK-7] INFO com.huyi.csdn.tools.SemaphoreDemo - 进攻结束
17:23:38.630 [TASK-7] INFO com.huyi.csdn.tools.SemaphoreDemo - code:7-结束任务
17:23:39.565 [ENGINE-1] INFO com.huyi.csdn.tools.SemaphoreDemo - 队列为空,无任务需要执行
17:23:40.563 [ENGINE-1] INFO com.huyi.csdn.tools.SemaphoreDemo - 队列为空,无任务需要执行
17:23:41.567 [ENGINE-1] INFO com.huyi.csdn.tools.SemaphoreDemo - 队列为空,无任务需要执行
17:23:42.562 [ENGINE-1] INFO com.huyi.csdn.tools.SemaphoreDemo - 队列为空,无任务需要执行
17:23:43.572 [ENGINE-1] INFO com.huyi.csdn.tools.SemaphoreDemo - 队列为空,无任务需要执行
17:23:44.563 [ENGINE-1] INFO com.huyi.csdn.tools.SemaphoreDemo - 队列为空,无任务需要执行
17:23:45.572 [ENGINE-1] INFO com.huyi.csdn.tools.SemaphoreDemo - 队列为空,无任务需要执行
17:23:46.563 [ENGINE-1] INFO com.huyi.csdn.tools.SemaphoreDemo - 队列为空,无任务需要执行
17:23:47.571 [ENGINE-1] INFO com.huyi.csdn.tools.SemaphoreDemo - 队列为空,无任务需要执行
17:23:48.561 [ENGINE-1] INFO com.huyi.csdn.tools.SemaphoreDemo - 队列为空,无任务需要执行
17:23:49.572 [ENGINE-1] INFO com.huyi.csdn.tools.SemaphoreDemo - 队列为空,无任务需要执行
17:23:50.570 [ENGINE-1] INFO com.huyi.csdn.tools.SemaphoreDemo - 队列为空,无任务需要执行

Process finished with exit code -1

可以看出,始终只有3个任务在执行任务。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值