Redis缓存:万字长文!从底层开始带你了解并发编程 上

简介: Redis缓存:万字长文!从底层开始带你了解并发编程


![](https://upload-images.jianshu.io/upload_images/24195226-95f672f1095c9a4c.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
我们都知道`StringBuffer`是线程安全的,因为他的关键方法都加了`synchronized`,但是,从打印结果可以看出,锁被消除了。因为`buffer`这个引用只会在`main`方法中使用,不可能被其他线程引用(因为是局部变量,栈私有),所以`buffer`是不可能共享的资源,`JVM`会自动消除`StringBuffer`对象内部的锁。
## 锁粗化

/**

  • @author yhd
  • @createtime 2020/9/8 20:48
    */
    public class Demo3 {
    public static void main(String[] args) {
    int i=0;
    StringBuffer buffer = new StringBuffer();
    while (i<100){
    buffer.append(i);
    i++;
    }
    System.out.println(buffer.toString());
    System.out.println(ClassLayout.parseInstance(buffer).toPrintable());
    }
    }
`JVM`会检测到这样一连串的操作都对同一个对象加锁(`while` 循环内 100 次执行 `append`,没有锁粗化的就要进行 100 次加锁/解锁),此时 `JVM` 就会将加锁的范围粗化到这一连串的操作的外部(比如 `while` 虚幻体外),使得这一连串操作只需要加一次锁即可。
## AQS
研究了`AQS`一天,终于找到了他的入口,接下来看我的想法:
## 多线程操做共享数据问题

/**

  • @author yhd
  • @createtime 2020/9/8 8:11
    */
    public class Demo1 {
    public static int m=0;
    public static void main(String[] args)throws Exception {
    Thread []threads=new Thread[100];
    for (int i = 0; i < threads.length; i++) {
    threads[i]=new Thread(()->{
    for (int j = 0; j < 100; j++) {
    m++;
    }
    });
    }
    for (Thread t :threads) t.start();
    for (Thread t :threads) t.join();//线程顺序结束
    System.out.println(m);
    }
    }
毫无疑问,这段代码是存在线程安全问题的,只要了解一点并发编程,都是可以看出来的。那么我们可以怎么来解决呢?
**使用synchronized来解决**

/**

  • @author yhd
  • @createtime 2020/9/8 8:32
    */
    public class Demo2 {
    public static int m=0;
    public static void main(String[] args)throws Exception {
    Thread []threads=new Thread[100];
    for (int i = 0; i < threads.length; i++) {
    threads[i]=new Thread(()->{
    synchronized (Demo2.class) {
    for (int j = 0; j < 100; j++) {
    m++;
    }
    }
    });
    }
    for (Thread t :threads) t.start();
    for (Thread t :threads) t.join();//线程顺序结束
    System.out.println(m);
    }
这样解决了线程安全的问题,但是同时也存在一个问题,synchronized是操作系统层面的方法,也就是需要jvm和操做系统之间进行一个切换(用户态和内核态的切换),这样实际上是影响效率的。另一种解决办法:
**使用ReentrantLock来解决**

/**

  • @author yhd
  • @createtime 2020/9/8 8:41
    */
    public class Demo3 {
    public static int m=0;
    public static Lock lock=new ReentrantLock();
    public static void main(String[] args)throws Exception {
    Thread []threads=new Thread[100];
    for (int i = 0; i < threads.length; i++) {
    threads[i]=new Thread(()->{
         try {
             lock.lock();
             for (int j = 0; j < 100; j++) {
                     m++;
             }
         } finally {
             lock.unlock();
         }
     });
 }
 for (Thread t :threads) t.start();
 for (Thread t :threads) t.join();//线程顺序结束
 System.out.println(m);
  • }
    }
那么这种方式的底层是怎么做的呢?接下来追源码。
public ReentrantLock() {
    sync = new NonfairSync();
}
这个sync又是啥呢?接着追
static final class NonfairSync extends Sync {
    private static final long serialVersionUID = 7316153563782823691L;
    /**
     * Performs lock.  Try immediate barge, backing up to normal
     * acquire on failure.
     */
    final void lock() {
        if (compareAndSetState(0, 1))
            setExclusiveOwnerThread(Thread.currentThread());
        else
            acquire(1);
    }
    protected final boolean tryAcquire(int acquires) {
        return nonfairTryAcquire(acquires);
    }
}
它实际上是`ReentrantLock`的一个内部类继承了`Sync`,而他里面的方法实际上也是调用了`Sync`的方法。这样目标就明确了,我们可以看一下`Sync`。 这个`Sync`的源码:

abstract static class Sync extends AbstractQueuedSynchronizer

由此可知,实际上`ReentrantLock`是利用`AbstractQueuedSynchronizer`也就是`AQS`来实现的。
## AbstractQueuedSynchronizer概述
这个类的内部有一个内部类`Node`

static final class Node {

static final Node SHARED = new Node();

volatile Node prev;//前驱指针

volatile Node next;//后继指针

volatile Thread thread;//当前线程

private transient volatile Node head;//头节点

private transient volatile Node tail;//尾节点

private volatile int state;//锁状态,加锁成功则为1,重入+1 解锁则为0

}

看到这里就想到了`LinkedHashMap`,实际上也就是类似于维持了一个双向的链表,每个节点都是一个线程。
![](https://upload-images.jianshu.io/upload_images/24195226-6f5395f4acfffc0d.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
它维护了一个volatile int state(代表共享资源)和一个FIFO线程等待队列(多线程争用资源被阻塞时会进入此队列)。这里volatile是核心关键词,具体volatile的语义,在此不述。state的访问方式有三种:
getState()
setState()
compareAndSetState()
`AQS`定义两种资源共享方式:`Exclusive`(独占,只有一个线程能执行,如`ReentrantLock`)和`Share`(共享,多个线程可同时执行,如`Semaphore/CountDownLatch`)。   不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源state的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在顶层实现好了。自定义同步器实现时主要实现以下几种方法:

isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它。

tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false。

tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false。

tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。

tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false。

以`ReentrantLock`为例,state初始化为0,表示未锁定状态。A线程lock()时,会调用`tryAcquire()`独占该锁并将state+1。此后,其他线程再`tryAcquire()`时就会失败,直到A线程unlock()到state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A线程自己是可以重复获取此锁的(state会累加),这就是可重入的概念。但要注意,获取多少次就要释放多么次,这样才能保证state是能回到零态的。
再以`CountDownLatch`以例,任务分为N个子线程去执行,state也初始化为N(注意N要与线程个数一致)。这N个子线程是并行执行的,每个子线程执行完后`countDown()`一次,state会`CAS`减1。等到所有子线程都执行完后(即state=0),会`unpark()`主调用线程,然后主调用线程就会从await()函数返回,继续后余动作。
一般来说,自定义同步器要么是独占方法,要么是共享方式,他们也只需实现`tryAcquire-tryRelease`、`tryAcquireShared-tryReleaseShared`中的一种即可。但AQS也支持自定义同步器同时实现独占和共享两种方式,如`ReentrantReadWriteLock`。
## 源码解读与执行流程分析
## 独占锁
acquire(int)== 此方法是独占模式下线程获取共享资源的顶层入口。如果获取到资源,线程直接返回,否则进入等待队列,直到获取到资源为止,且整个过程忽略中断的影响。这也正是lock()的语义,当然不仅仅只限于lock()。获取到资源后,线程就可以去执行其临界区代码了。
public final void acquire(int arg) {
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}
函数流程如下:
`tryAcquire()`尝试直接去获取资源,如果成功则直接返回; `addWaiter()`将该线程加入等待队列的尾部,并标记为独占模式; `acquireQueued()`使线程在等待队列中获取资源,一直获取到资源后才返回。如果在整个等待过程中被中断过,则返回true,否则返回false。如果线程在等待过程中被中断过,它是不响应的。只是获取资源后才再进行自我中断selfInterrupt(),将中断补上。 **tryAcquire(int)** 此方法尝试去获取独占资源。如果获取成功,则直接返回true,否则直接返回false。这也正是tryLock()的语义,还是那句话,当然不仅仅只限于tryLock()。
protected boolean tryAcquire(int arg) {
    throw new UnsupportedOperationException();
}
一开始还傻乎乎的想为啥直接抛异常了,后来才反应过来,这不是给自定义的方法么?AQS这里只定义了一个接口,具体资源的获取交由自定义同步器去实现了。 **addWaiter(Node)** 此方法用于将当前线程加入到等待队列的队尾,并返回当前线程所在的结点。
private Node addWaiter(Node mode) {
 //以给定模式构造结点。mode有两种:EXCLUSIVE(独占)和SHARED(共享)
    Node node = new Node(Thread.currentThread(), mode);
    // Try the fast path of enq; backup to full enq on failure
    Node pred = tail;
    if (pred != null) {
        node.prev = pred;
        if (compareAndSetTail(pred, node)) {
            pred.next = node;
            return node;
        }
    }
    enq(node);
    return node;
}
Node结点是对每一个访问同步代码的线程的封装,其包含了需要同步的线程本身以及线程的状态,如是否被阻塞,是否等待唤醒,是否已经被取消等。变量waitStatus则表示当前被封装成Node结点的等待状态,共有4种取值CANCELLED、SIGNAL、CONDITION、PROPAGATE。
`CANCELLED`:值为1,在同步队列中等待的线程等待超时或被中断,需要从同步队列中取消该Node的结点,其结点的waitStatus为CANCELLED,即结束状态,进入该状态后的结点将不会再变化。
`SIGNAL`:值为-1,被标识为该等待唤醒状态的后继结点,当其前继结点的线程释放了同步锁或被取消,将会通知该后继结点的线程执行。说白了,就是处于唤醒状态,只要前继结点释放锁,就会通知标识为SIGNAL状态的后继结点的线程执行。
`CONDITION`:值为-2,与Condition相关,该标识的结点处于等待队列中,结点的线程等待在Condition上,当其他线程调用了Condition的signal()方法后,CONDITION状态的结点将从等待队列转移到同步队列中,等待获取同步锁。
`PROPAGATE`:值为-3,与共享模式相关,在共享模式中,该状态标识结点的线程处于可运行状态。
`0`状态:值为0,代表初始化状态。
AQS在判断状态时,通过用waitStatus>0表示取消状态,而waitStatus<0表示有效状态。
== enq(Node)== 此方法用于将node加入队尾。
private Node enq(final Node node) {
    for (;;) {//自旋
        Node t = tail;
        if (t == null) { // Must initialize
        // 队列为空,创建一个空的标志结点作为head结点,并将tail也指向它
            if (compareAndSetHead(new Node()))
                tail = head;
        } else {//正常放入队列尾部
            node.prev = t;
            if (compareAndSetTail(t, node)) {
                t.next = node;
                return t;
            }
        }
    }
}
`cas自旋volatile变量` **acquireQueued(Node, int)** 通过tryAcquire()和addWaiter(),该线程获取资源失败,已经被放入等待队列尾部了。聪明的你立刻应该能想到该线程下一步该干什么了吧:进入等待状态休息,直到其他线程彻底释放资源后唤醒自己,自己再拿到资源,然后就可以去干自己想干的事了。没错,就是这样!是不是跟医院排队拿号有点相似~~acquireQueued()就是干这件事:在等待队列中排队拿号(中间没其它事干可以休息),直到拿到号后再返回。这个函数非常关键,还是上源码吧:
final boolean acquireQueued(final Node node, int arg) {
    boolean failed = true;//标记是否已经拿到锁
    try {
        boolean interrupted = false;//标记等待过程中是否被中断过
        for (;;) {
            final Node p = node.predecessor();//拿到前驱节点
            //如果前驱是head,即该结点已成老二,那么便有资格去尝试获取资源(可能是老大释放完资源唤醒自己的,当然也可能被interrupt了)。
            if (p == head && tryAcquire(arg)) {
                setHead(node);
                //拿到资源后,将head指向该结点。所以head所指的标杆结点,就是当前获取到资源的那个结点或null。
                p.next = null; // help GC
                failed = false;
                return interrupted;
            }
            //如果自己可以休息了,就进入waiting状态,直到被unpark()
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true;//如果等待过程中被中断过,哪怕只有那么一次,就将interrupted标记为true
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}
**shouldParkAfterFailedAcquire(Node, Node)** 此方法主要用于检查状态,看看自己是否真的可以去休息了。
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    int ws = pred.waitStatus;//拿到前驱的状态
    if (ws == Node.SIGNAL)
        //如果已经告诉前驱拿完号后通知自己一下,那就可以安心休息了
        return true;
    if (ws > 0) {
       /*
       * 如果前驱放弃了,那就一直往前找,直到找到最近一个正常等待的状态,并排在它的后边。
       * 注意:那些放弃的结点,由于被自己“加塞”到它们前边,它们相当于形成一个无引用链,稍后就会被保安大叔赶走了(GC回收)!
      */
        do {
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        pred.next = node;
    } else {
        //如果前驱正常,那就把前驱的状态设置成SIGNAL,告诉它拿完号后通知自己一下。有可能失败,人家说不定刚刚释放完呢!
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }
    return false;
}
整个流程中,如果前驱结点的状态不是SIGNAL,那么自己就不能安心去休息,需要去找个安心的休息点,同时可以再尝试下看有没有机会轮到自己拿号。 **parkAndCheckInterrupt()** 如果线程找好安全休息点后,那就可以安心去休息了。此方法就是让线程去休息,真正进入等待状态。
private final boolean parkAndCheckInterrupt() {
    LockSupport.park(this);
    return Thread.interrupted();
}

``


相关文章
|
6月前
|
缓存 负载均衡 监控
135_负载均衡:Redis缓存 - 提高缓存命中率的配置与最佳实践
在现代大型语言模型(LLM)部署架构中,缓存系统扮演着至关重要的角色。随着LLM应用规模的不断扩大和用户需求的持续增长,如何构建高效、可靠的缓存架构成为系统性能优化的核心挑战。Redis作为业界领先的内存数据库,因其高性能、丰富的数据结构和灵活的配置选项,已成为LLM部署中首选的缓存解决方案。
658 25
|
6月前
|
缓存 运维 监控
Redis 7.0 高性能缓存架构设计与优化
🌟蒋星熠Jaxonic,技术宇宙中的星际旅人。深耕Redis 7.0高性能缓存架构,探索函数化编程、多层缓存、集群优化与分片消息系统,用代码在二进制星河中谱写极客诗篇。
1108 3
|
7月前
|
存储 缓存 NoSQL
Redis专题-实战篇二-商户查询缓存
本文介绍了缓存的基本概念、应用场景及实现方式,涵盖Redis缓存设计、缓存更新策略、缓存穿透问题及其解决方案。重点讲解了缓存空对象与布隆过滤器的使用,并通过代码示例演示了商铺查询的缓存优化实践。
318 1
Redis专题-实战篇二-商户查询缓存
|
7月前
|
缓存 NoSQL 关系型数据库
Redis缓存和分布式锁
Redis 是一种高性能的键值存储系统,广泛用于缓存、消息队列和内存数据库。其典型应用包括缓解关系型数据库压力,通过缓存热点数据提高查询效率,支持高并发访问。此外,Redis 还可用于实现分布式锁,解决分布式系统中的资源竞争问题。文章还探讨了缓存的更新策略、缓存穿透与雪崩的解决方案,以及 Redlock 算法等关键技术。
|
11月前
|
缓存 NoSQL 关系型数据库
美团面试:MySQL有1000w数据,redis只存20w的数据,如何做 缓存 设计?
美团面试:MySQL有1000w数据,redis只存20w的数据,如何做 缓存 设计?
美团面试:MySQL有1000w数据,redis只存20w的数据,如何做 缓存 设计?
|
11月前
|
缓存 NoSQL Java
Redis+Caffeine构建高性能二级缓存
大家好,我是摘星。今天为大家带来的是Redis+Caffeine构建高性能二级缓存,废话不多说直接开始~
1434 0
|
11月前
|
消息中间件 缓存 NoSQL
基于Spring Data Redis与RabbitMQ实现字符串缓存和计数功能(数据同步)
总的来说,借助Spring Data Redis和RabbitMQ,我们可以轻松实现字符串缓存和计数的功能。而关键的部分不过是一些"厨房的套路",一旦你掌握了这些套路,那么你就像厨师一样可以准备出一道道饕餮美食了。通过这种方式促进数据处理效率无疑将大大提高我们的生产力。
342 32
|
11月前
|
缓存 NoSQL Java
Redis:现代服务端开发的缓存基石与电商实践-优雅草卓伊凡
Redis:现代服务端开发的缓存基石与电商实践-优雅草卓伊凡
270 5
Redis:现代服务端开发的缓存基石与电商实践-优雅草卓伊凡
|
人工智能 缓存 NoSQL
Redis 与 AI:从缓存到智能搜索的融合之路
Redis 已从传统缓存系统发展为强大的 AI 支持平台,其向量数据库功能和 RedisAI 模块为核心,支持高维向量存储、相似性搜索及模型服务。文章探讨了 Redis 在实时数据缓存、语义搜索与会话持久化中的应用场景,并通过代码案例展示了与 Spring Boot 的集成方式。总结来看,Redis 结合 AI 技术,为现代应用提供高效、灵活的解决方案。
|
缓存 监控 NoSQL
Redis--缓存击穿、缓存穿透、缓存雪崩
缓存击穿、缓存穿透和缓存雪崩是Redis使用过程中可能遇到的常见问题。理解这些问题的成因并采取相应的解决措施,可以有效提升系统的稳定性和性能。在实际应用中,应根据具体场景,选择合适的解决方案,并持续监控和优化缓存策略,以应对不断变化的业务需求。
2148 29
下一篇
开通oss服务