您当前的位置:首页 > 电脑百科 > 程序开发 > 容器

并发容器ConcurrentHashMap

时间:2020-10-16 10:50:23  来源:  作者:

突击并发编程JUC系列演示代码地址:https://github.com/mtcarpenter/JAVATutorial

作者:故人

出处:https://segmentfault.com/a/1190000037493581

 

本节让我们一起研究一下该容器是如何在保证线程安全的同时又能保证高效的操作。 ConcurrentHashMap 是线程安全且高效的 HashMap 。

为什么要使用ConcurrentHashMap

在并发编程中使用 HashMap 可能导致程序死循环。而使用线程安全的 HashTable 效率又非常低下,基于以上两个原因,便有了 ConcurrentHashMap 的登场机会。

线程不安全的HashMap

在多线程环境下,使用 HashMap 进行put操作会引起死循环,导致CPU利用率接近100%,所以在并发情况下不能使用 HashMap 。例如,执行以下代码会引起死循环。

public class HashMapExample {

    // 请求总数
    public static int clientTotal = 5000;

    // 同时并发执行的线程数
    public static int threadTotal = 200;

    private static Map<Integer, Integer> map = new HashMap<>(2);

    public static void main(String[] args) throws Exception {
        ExecutorService executorService = Executors.newFixedThreadPool(threadTotal);
        final CountDownLatch countDownLatch = new CountDownLatch(clientTotal);
        for (int i = 0; i < clientTotal; i++) {
            final int count = i;
            executorService.execute(() -> {
                try {
                    update(count);
                } catch (Exception e) {
                    e.printStackTrace();
                }
                countDownLatch.countDown();
            });
        }
        countDownLatch.await();
        executorService.shutdown();
        System.out.println("size:"+ map.size());
    }

    private static void update(int i) {
        map.put(i, i);
    }
}

HashMap 在并发执行put操作时会引起死循环,是因为多线程会导致 HashMap 的 Entry 链表形成环形数据结构,一旦形成环形数据结构,Entry 的 next 节点永远不为空,就会产生死循环获取 Entry。

效率低下的HashTable

HashTable 容器使用 synchronized 来保证线程安全,但在线程竞争激烈的情况下 HashTable 的效率非常低下。因为当一个线程访问 HashTable 的同步方法,其他线程也访问 HashTable 的同步方法时,会进入阻塞或轮询状态。如线程 1 使用 put 进行元素添加,线程2不但不能使用put方法添加元素,也不能使用 get 方法来获取元素,所以竞争越激烈效率越低。

ConcurrentHashMap 的锁分段技术可有效提升并发访问率

HashTable 容器在竞争激烈的并发环境下表现出效率低下的原因是所有访问 HashTable 的线程都必须竞争同一把锁,假如容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效提高并发访问效率,这就是 ConcurrentHashMap 所使用的锁分段技术。首先将数据分成一段一段地存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。

ConcurrentHashMapExample 示例

public class ConcurrentHashMapExample {

    // 请求总数
    public static int clientTotal = 5000;

    // 同时并发执行的线程数
    public static int threadTotal = 200;

    private static Map<Integer, Integer> map = new ConcurrentHashMap<>();

    public static void main(String[] args) throws Exception {
        ExecutorService executorService = Executors.newFixedThreadPool(threadTotal);
        final CountDownLatch countDownLatch = new CountDownLatch(clientTotal);
        for (int i = 0; i < clientTotal; i++) {
            final int count = i;
            executorService.execute(() -> {
                try {
                    update(count);
                } catch (Exception e) {
                    e.printStackTrace();
                }
                countDownLatch.countDown();
            });
        }
        countDownLatch.await();
        executorService.shutdown();
        System.out.println("size:" + map.size());
    }

    private static void update(int i) {
        map.put(i, i);
    }
}

ConcurrentHashMap JDK 1.7/JDK 1.8

  • JDK 1.7 中使用分段锁(ReentrantLock + Segment + HashEntry),相当于把一个 HashMap 分成多个段,每段分配一把锁,这样支持多线程访问。锁粒度:基于 Segment,包含多个 HashEntry。
  • JDK 1.8 中使用 CAS + synchronized + Node + 红黑树。锁粒度:Node(首结点)(实现 Map.Entry)。锁粒度降低了。

JDK 1.7 结构

并发容器ConcurrentHashMap

 

JDK 1.7 中的 ConcurrentHashMap 内部进行了 Segment 分段, Segment 继承了 ReentrantLock ,可以理解为一把锁,各个 Segment 之间都是相互独立上锁的,互不影响。

相比于之前的 Hashtable 每次操作都需要把整个对象锁住而言,大大提高了并发效率。因为它的锁与锁之间是独立的,而不是整个对象只有一把锁。

每个 Segment 的底层数据结构与 HashMap 类似,仍然是数组和链表组成的拉链法结构。默认有 0~15 共 16 个 Segment ,所以最多可以同时支持 16 个线程并发操作(操作分别分布在不同的 Segment 上)。16 这个默认值可以在初始化的时候设置为其他值,但是一旦确认初始化以后,是不可以扩容的。

JDK 1.8 结构

并发容器ConcurrentHashMap

 

图中的节点有三种类型:

HashMap
ConcurrentHashMap

链表长度大于某一个阈值(默认为 8),满足容量从链表的形式转化为红黑树的形式。

红黑树是每个节点都带有颜色属性的二叉查找树,颜色为红色或黑色,红黑树的本质是对二叉查找树 BST 的一种平衡策略,我们可以理解为是一种平衡二叉查找树,查找效率高,会自动平衡,防止极端不平衡从而影响查找效率的情况发生,红黑树每个节点要么是红色,要么是黑色,但根节点永远是黑色的。

ConcurrentHashMap 源码

常量说明

/**
     * table 桶数最大值,且前两位用作控制标志
     */
    private static final int MAXIMUM_CAPACITY = 1 << 30;

    /**
     * table 桶数初始化默认值,需为2的幂次方
     */
    private static final int DEFAULT_CAPACITY = 16;

    /**
     * 数组可能最大值,需要与toArray()相关方法关联  
     */
    static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;

    /**
     *并发级别,遗留下来的,为兼容以前的版本  
     */
    private static final int DEFAULT_CONCURRENCY_LEVEL = 16;

    /**
     * 加载因子,扩容的阀值,可在构造方法定义
     */
    private static final float LOAD_FACTOR = 0.75f;

    /**
     * 链表转红黑树阀值, 超过 8 链表转换为红黑树  
     */
    static final int TREEIFY_THRESHOLD = 8;

    /**
     * 树转链表阀值,小于等于6(tranfer时,lc、hc=0两个计数器分别++记录原bin、新binTreeNode数量,<=UNTREEIFY_THRESHOLD 则untreeify(lo))  

     */
    static final int UNTREEIFY_THRESHOLD = 6;

    /**
     * 树化阀值2,当数组桶树达到64以上才允许链表树化
     */
    static final int MIN_TREEIFY_CAPACITY = 64;

初始化 table

private final Node<K,V>[] initTable() {
    Node<K,V>[] tab; int sc;
    // 初始化成功退出循环
    while ((tab = table) == null || tab.length == 0) {
      if ((sc = sizeCtl) < 0)
        Thread.yield(); // 有其他线程在初始化,自旋等待
      else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
        // 进入初始化 sizeCtl = -1
        try {
          if ((tab = table) == null || tab.length == 0) {
            int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
            Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
            table = tab = nt;
            // sc = n-n/4 ???
            sc = n - (n >>> 2);
          }
        } finally {
          // 初始化成功设置 sizeCtl
          sizeCtl = sc;
        }
        break;
      }
    }
    return tab;
}

get 方法

public V get(Object key) {
    Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
    int h = spread(key.hashCode());
    
    // 检查 table 是否存在,hashCode 所在的索引是有为空
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (e = tabAt(tab, (n - 1) & h)) != null) {
        
        if ((eh = e.hash) == h) {
            if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                return e.val;
        }
      
        // 按照树的方式遍历 Node 查找
        else if (eh < 0)
            return (p = e.find(h, key)) != null ? p.val : null;
            
        // 按照链表的方式遍历 Node 查找    
        while ((e = e.next) != null) {
            if (e.hash == h &&
                ((ek = e.key) == key || (ek != null && key.equals(ek))))
                return e.val;
        }
    }
    return null;
}

总结一下 get 的过程:

  • 计算 Hash 值,并由此值找到对应的槽点;
  • 如果数组是空的或者该位置为 null,那么直接返回 null 就可以了;
  • 如果该位置处的节点刚好就是我们需要的,直接返回该节点的值;
  • 如果该位置节点是红黑树或者正在扩容,就用 find 方法继续查找;
  • 否则那就是链表,就进行遍历链表查找

put 方法说明

public V put(K key, V value) {
    return putVal(key, value, false);
}
    
final V putVal(K key, V value, boolean onlyIfAbsent) {
    // 计算 key 的 hashCode
    int hash = spread(key.hashCode());
    int binCount = 0;
    
    // 循环直到插入成功,
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh;
        if (tab == null || (n = tab.length) == 0)
            // 初始化 table
            tab = initTable();
        
        // tabAt 调用 getObjectVolatile 
        // 当前位置为空可以直接插入的情况    
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            
            // 通过 CAS 操作插入,不需要加锁
            if (casTabAt(tab, i, null,
                         new Node<K,V>(hash, key, value, null)))
                break;              
        }
        
        // 下面是位置已经有值的情况
        
        // MOVED 表示当前 Map 正在进行扩容
        else if ((fh = f.hash) == MOVED)
            // 帮助进行扩容,然后进入下一次循环尝试插入
            tab = helpTransfer(tab, f);
      
        // 未在扩容的情况
        else {
            V oldVal = null;
            
            // 对f加锁,f 是存储在当前位置的 Node 的头节点
            synchronized (f) {
                // 双重检查,保证 Node 头节点没有改变
                if (tabAt(tab, i) == f) {
                    if (fh >= 0) {
                        // 对链表进行操作
                        binCount = 1;
                        for (Node<K,V> e = f;; ++binCount) {
                            // 更新值(判断 onlyIfAbsent)或插入链表尾部 ...
                            break;
                        }
                    }
                    else if (f instanceof TreeBin) {
                        // 对树进行操作 ...
                    }
                }
            }
            
            // 判断是否需要 treeify
            if (binCount != 0) {
                if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i);
                if (oldVal != null)
                    return oldVal;
                break;
            }
        }
    }
    
    addCount(1L, binCount);
    return null;
}

总结一下 put 的过程:

  • 判断Node[] 数组是否初始化,没有则进行初始化操作
  • 通过 hash 定位数组的索引坐标,是否有 Node 节点,如果没有则使用 CAS 进行添加(链表的头节点),添加失败则进入下次循环。
  • 检查到内部正在扩容,就帮助它一块扩容。
  • 如果 f != null ,则使用 synchronized 锁住 f 元素(链表/红黑二叉树的头元素)如果是 Node (链表结构)则执行链表的添加操作如果是 TreeNode (树形结构)则执行树添加操作。
  • 判断链表长度已经达到临界值 8 ,当然这个 8 是默认值,大家也可以去做调整,当节点数超过这个值就需要把链表转换为树结构。

tryPresize方法

private final void tryPresize(int size) {
        //计算扩容的目标size
        int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY :
            tableSizeFor(size + (size >>> 1) + 1);
        int sc;
        while ((sc = sizeCtl) >= 0) {
            Node<K,V>[] tab = table; int n;
            //tab没有初始化
            if (tab == null || (n = tab.length) == 0) {
                n = (sc > c) ? sc : c;
                //初始化之前,CAS设置sizeCtl=-1 
                if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
                    try {
                        if (table == tab) {
                            @SuppressWarnings("unchecked")
                            Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                            table = nt;
                            //sc=0.75n,相当于扩容阈值
                            sc = n - (n >>> 2);
                        }
                    } finally {
                        //此时并没有通过CAS赋值,因为其他想要执行初始化的线程,发现sizeCtl=-1,就直接返回,从而确保任何情况,只会有一个线程执行初始化操作。
                        sizeCtl = sc;
                    }
                }
            }
            //目标扩容size小于扩容阈值,或者容量超过最大限制时,不需要扩容
            else if (c <= sc || n >= MAXIMUM_CAPACITY)
                break;
            //扩容
            else if (tab == table) {
                int rs = resizeStamp(n);
                //sc<0表示,已经有其他线程正在扩容
                if (sc < 0) {
                    Node<K,V>[] nt;
                      /**
                      1 (sc >>> RESIZE_STAMP_SHIFT) != rs :扩容线程数 > MAX_RESIZERS-1
                      2 sc == rs + 1 和 sc == rs + MAX_RESIZERS :表示什么???
                      3 (nt = nextTable) == null :表示nextTable正在初始化
                      4 transferIndex <= 0 :表示所有hash桶均分配出去
                    */
                    //如果不需要帮其扩容,直接返回
                    if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
                        sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
                        transferIndex <= 0)
                        break;
                    //CAS设置sizeCtl=sizeCtl+1
                    if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
                        //帮其扩容
                        transfer(tab, nt);
                }
                //第一个执行扩容操作的线程,将sizeCtl设置为:(resizeStamp(n) << RESIZE_STAMP_SHIFT) + 2)
                else if (U.compareAndSwapInt(this, SIZECTL, sc,
                                             (rs << RESIZE_STAMP_SHIFT) + 2))
                    transfer(tab, null);
            }
        }
    }

此段代码参考网址: https://www.jianshu.com/p/487d00afe6ca

transfer方法

private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
        int n = tab.length, stride;
        //计算需要迁移多少个hash桶(MIN_TRANSFER_STRIDE该值作为下限,以避免扩容线程过多)
        if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
            stride = MIN_TRANSFER_STRIDE; // subdivide range
       
        if (nextTab == null) {            // initiating
            try {
                //扩容一倍
                @SuppressWarnings("unchecked")
                Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
                nextTab = nt;
            } catch (Throwable ex) {      // try to cope with OOME
                sizeCtl = Integer.MAX_VALUE;
                return;
            }
            nextTable = nextTab;
            transferIndex = n;
        }
        int nextn = nextTab.length;
        ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
        boolean advance = true;
        boolean finishing = false; // to ensure sweep before committing nextTab
      
        //1 逆序迁移已经获取到的hash桶集合,如果迁移完毕,则更新transferIndex,获取下一批待迁移的hash桶
        //2 如果transferIndex=0,表示所以hash桶均被分配,将i置为-1,准备退出transfer方法
        for (int i = 0, bound = 0;;) {
            Node<K,V> f; int fh;
            
            //更新待迁移的hash桶索引
            while (advance) {
                int nextIndex, nextBound;
                //更新迁移索引i。
                if (--i >= bound || finishing)
                    advance = false;
                else if ((nextIndex = transferIndex) <= 0) {
                    //transferIndex<=0表示已经没有需要迁移的hash桶,将i置为-1,线程准备退出
                    i = -1;
                    advance = false;
                }
                //当迁移完bound这个桶后,尝试更新transferIndex,,获取下一批待迁移的hash桶
                else if (U.compareAndSwapInt
                         (this, TRANSFERINDEX, nextIndex,
                          nextBound = (nextIndex > stride ?
                                       nextIndex - stride : 0))) {
                    bound = nextBound;
                    i = nextIndex - 1;
                    advance = false;
                }
            }
            //退出transfer
            if (i < 0 || i >= n || i + n >= nextn) {
                int sc;
                if (finishing) {
                    //最后一个迁移的线程,recheck后,做收尾工作,然后退出
                    nextTable = null;
                    table = nextTab;
                    sizeCtl = (n << 1) - (n >>> 1);
                    return;
                }
                if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
                    /**
                     第一个扩容的线程,执行transfer方法之前,会设置 sizeCtl = (resizeStamp(n) << RESIZE_STAMP_SHIFT) + 2)
                     后续帮其扩容的线程,执行transfer方法之前,会设置 sizeCtl = sizeCtl+1
                     每一个退出transfer的方法的线程,退出之前,会设置 sizeCtl = sizeCtl-1
                     那么最后一个线程退出时:
                     必然有sc == (resizeStamp(n) << RESIZE_STAMP_SHIFT) + 2),即 (sc - 2) == resizeStamp(n) << RESIZE_STAMP_SHIFT
                    */
                    
                    //不相等,说明不到最后一个线程,直接退出transfer方法
                    if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
                        return;
                    finishing = advance = true;
                    //最后退出的线程要重新check下是否全部迁移完毕
                    i = n; // recheck before commit
                }
            }
            else if ((f = tabAt(tab, i)) == null)
                advance = casTabAt(tab, i, null, fwd);
            else if ((fh = f.hash) == MOVED)
                advance = true; // already processed
            //迁移node节点
            else {
                synchronized (f) {
                    if (tabAt(tab, i) == f) {
                        Node<K,V> ln, hn;
                        //链表迁移
                        if (fh >= 0) {
                            int runBit = fh & n;
                            Node<K,V> lastRun = f;
                            for (Node<K,V> p = f.next; p != null; p = p.next) {
                                int b = p.hash & n;
                                if (b != runBit) {
                                    runBit = b;
                                    lastRun = p;
                                }
                            }
                            if (runBit == 0) {
                                ln = lastRun;
                                hn = null;
                            }
                            else {
                                hn = lastRun;
                                ln = null;
                            }
                            //将node链表,分成2个新的node链表
                            for (Node<K,V> p = f; p != lastRun; p = p.next) {
                                int ph = p.hash; K pk = p.key; V pv = p.val;
                                if ((ph & n) == 0)
                                    ln = new Node<K,V>(ph, pk, pv, ln);
                                else
                                    hn = new Node<K,V>(ph, pk, pv, hn);
                            }
                            //将新node链表赋给nextTab
                            setTabAt(nextTab, i, ln);
                            setTabAt(nextTab, i + n, hn);
                            setTabAt(tab, i, fwd);
                            advance = true;
                        }
                        //红黑树迁移
                        else if (f instanceof TreeBin) {
                            TreeBin<K,V> t = (TreeBin<K,V>)f;
                            TreeNode<K,V> lo = null, loTail = null;
                            TreeNode<K,V> hi = null, hiTail = null;
                            int lc = 0, hc = 0;
                            for (Node<K,V> e = t.first; e != null; e = e.next) {
                                int h = e.hash;
                                TreeNode<K,V> p = new TreeNode<K,V>
                                    (h, e.key, e.val, null, null);
                                if ((h & n) == 0) {
                                    if ((p.prev = loTail) == null)
                                        lo = p;
                                    else
                                        loTail.next = p;
                                    loTail = p;
                                    ++lc;
                                }
                                else {
                                    if ((p.prev = hiTail) == null)
                                        hi = p;
                                    else
                                        hiTail.next = p;
                                    hiTail = p;
                                    ++hc;
                                }
                            }
                            ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) :
                                (hc != 0) ? new TreeBin<K,V>(lo) : t;
                            hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
                                (lc != 0) ? new TreeBin<K,V>(hi) : t;
                            setTabAt(nextTab, i, ln);
                            setTabAt(nextTab, i + n, hn);
                            setTabAt(tab, i, fwd);
                            advance = true;
                        }
                    }
                }
            }
        }
    }

作者:故人

出处:https://segmentfault.com/a/1190000037493581

突击并发编程JUC系列演示代码地址:https://github.com/mtcarpenter/JavaTutorial



Tags:ConcurrentHashMap   点击:()  评论:()
声明:本站部分内容及图片来自互联网,转载是出于传递更多信息之目的,内容观点仅代表作者本人,如有任何标注错误或版权侵犯请与我们联系(Email:2595517585@qq.com),我们将及时更正、删除,谢谢。
▌相关推荐
本节让我们一起研究一下该容器是如何在保证线程安全的同时又能保证高效的操作。 ConcurrentHashMap 是线程安全且高效的 HashMap 。...【详细内容】
2020-10-16  Tags: ConcurrentHashMap  点击:(79)  评论:(0)  加入收藏
▌简易百科推荐
一、为什么要搭建主从架构呢1.数据安全,可以进行数据的备份。2.读写分离,大部分的业务系统来说都是读数据多,写数据少,当访问压力过大时,可以把读请求给到从服务器。从而缓解数据...【详细内容】
2021-12-15  实战Java    Tags:Docker   点击:(10)  评论:(0)  加入收藏
在网页中渲染公式一直是泛学术工具绕不开的一个功能,最近更新产品功能,正巧遇到了这个需求,于是使用容器方式简单实现了一个相对靠谱的公式渲染服务。分享出来,希望能够帮到有类...【详细内容】
2021-12-01  编程菌zfn    Tags:Docker   点击:(10)  评论:(0)  加入收藏
1.1 docker命令直接部署1.1.1 拉取镜像docker pull wurstmeister/zookeeperdocker pull wurstmeister/kafka1.1.2 启动zookeeper容器docker run -d --name myzookeeper -p 2...【详细内容】
2021-11-15  无    Tags:docker   点击:(47)  评论:(0)  加入收藏
01 前言 顺着docker的发展,很多测试的同学也已经在测试工作上使用docker作为环境基础去进行一些自动化测试,这篇文章主要讲述我们在docker中使用浏览器进行自动化测试如果可以...【详细内容】
2021-10-29  小码哥聊软件测试    Tags:Docker   点击:(42)  评论:(0)  加入收藏
因为你懂得的原因,下载docker镜像速度非常喜感,故收集几个国内常用的docker镜像。Docker中国区官方镜像地址:https://registry.docker-cn.com网易163的镜像http://hub-mirror.c...【详细内容】
2021-10-28  抓蛙程序猿    Tags:docker   点击:(48)  评论:(0)  加入收藏
环境:Spring5.3.10通常,应用程序开发人员不需要对ApplicationContext实现类进行子类化。相反,SpringIOC容器可以通过插入特殊集成接口的实现来扩展。使用BeanPostProcessor自定...【详细内容】
2021-10-26  Java网络研发架构师    Tags:Spring   点击:(33)  评论:(0)  加入收藏
我们在很多场景下都需要做笔记,来对抗遗忘,一份好的笔记不仅能在需要的时候供我们查阅,也能帮助我们归纳整理知识提高做事效率。 目前市面上有很多云笔记软件,体验上各有不同,但...【详细内容】
2021-10-11  运维贼船    Tags:docker   点击:(61)  评论:(0)  加入收藏
1. Nacos官网Nacos Docker 快速开始2. Clone 项目git clone https://github.com/nacos-group/nacos-docker.git3. cd 到nacos-docker 路径下 直接启动即可cd nacos-dockerdo...【详细内容】
2021-09-16  程序狗爱化妆    Tags:Nacos   点击:(109)  评论:(0)  加入收藏
今天不做保姆级教程,分享奶爸常用、好用的Docker应用。有了这些Docker,Nas的可玩性会大幅提高,有时候奶爸也在想,刨去官方套件不考虑的话,Nas真的是差不多。如果小伙伴们有需要,后...【详细内容】
2021-09-03  晋升奶爸的垃圾佬    Tags:Docker   点击:(167)  评论:(0)  加入收藏
环境要求 ubuntu系统:20.04 docker版本:20.10.7 redis版本:6.0.6步骤由于我这里已经有相应的redis镜像,这里就不记录了,关于docker一些基础知识可以看我以前的笔记开启3台re...【详细内容】
2021-07-26  石老师小跟班    Tags:Redis主从复制   点击:(117)  评论:(0)  加入收藏
相关文章
    无相关信息
最新更新
栏目热门
栏目头条