【Java技术探索】「ConcurrentHashMap」深入浅出的源码分析(JDK1(1)

那么如何才能正确的掌握Redis呢?

为了让大家能够在Redis上能够加深,所以这次给大家准备了一些Redis的学习资料,还有一些大厂的面试题,包括以下这些面试题

  • 并发编程面试题汇总

  • JVM面试题汇总

  • Netty常被问到的那些面试题汇总

  • Tomcat面试题整理汇总

  • Mysql面试题汇总

  • Spring源码深度解析

  • Mybatis常见面试题汇总

  • Nginx那些面试题汇总

  • Zookeeper面试题汇总

  • RabbitMQ常见面试题汇总

JVM常频面试:

Redis高频面试笔记:基础+缓存雪崩+哨兵+集群+Reids场景设计

Mysql面试题汇总(一)

Redis高频面试笔记:基础+缓存雪崩+哨兵+集群+Reids场景设计

Mysql面试题汇总(二)

Redis高频面试笔记:基础+缓存雪崩+哨兵+集群+Reids场景设计

Redis常见面试题汇总(300+题)

Redis高频面试笔记:基础+缓存雪崩+哨兵+集群+Reids场景设计

本文已被CODING开源项目:【一线大厂Java面试题解析+核心总结学习笔记+最新讲解视频+实战项目源码】收录

需要这份系统化的资料的朋友,可以点击这里获取

Node<K,V> find(int h, Object k) {

// loop to avoid arbitrarily deep recursion on forwarding nodes

outer: for (Node<K,V>[] tab = nextTable;😉 {

Node<K,V> e; int n;

// 如果无法定位或者定位到null则直接返回

if (k == null || tab == null || (n = tab.length) == 0 ||

(e = tabAt(tab, (n - 1) & h)) == null)

return null;

for (;😉 {

int eh; K ek;

// 遍历查找对比hash值和key值,从而进行分析是否相等。

if ((eh = e.hash) == h &&

((ek = e.key) == k || (ek != null && k.equals(ek))))

// 查找到后相等直接返回

return e;

//如果hash值为-1 或者 -2

if (eh < 0) {

// 如果是forwardingNode 则就是代表是-1

if (e instanceof ForwardingNode) {

//直接获取到下一个表。

tab = ((ForwardingNode<K,V>)e).nextTable;

continue outer;

}

else

return e.find(h, k);

}

//直到为null,直接返回

if ((e = e.next) == null)

return null;

}

}

}

}

Unsafe与CAS


在ConcurrentHashMap中,随处可以看到Unsafe, 大量使用了Unsafe.compareAndSwapXXX的方法,这个方法是利用一个CAS算法实现无锁化的修改值的操作,他可以大大降低锁代理的性能消耗

这个算法的基本思想就是不断地去比较当前内存中的变量值与你指定的一个变量值是否相等,如果相等,则接受你指定的修改的值,否则拒绝你的操作。

因为当前线程中的值已经不是最新的值,你的修改很可能会覆盖掉其他线程修改的结果。这一点与乐观锁,SVN的思想是比较类似的。

unsafe静态块

unsafe代码块控制了一些属性的修改工作,比如最常用的SIZECTL 。 在这一版本的concurrentHashMap中,大量应用来的CAS方法进行变量、属性的修改工作。 利用CAS进行无锁操作,可以大大提高性能。

// 各个字段属性的偏移量,可以通过偏移量+首地址获取到对应的数据对象

private static final sun.misc.Unsafe U;

private static final long SIZECTL;

private static final long TRANSFERINDEX;

private static final long BASECOUNT;

private static final long CELLSBUSY;

private static final long CELLVALUE;

private static final long ABASE;

private static final int ASHIFT;

static {

try {

U = sun.misc.Unsafe.getUnsafe();

Class<?> k = ConcurrentHashMap.class;

SIZECTL = U.objectFieldOffset

(k.getDeclaredField(“sizeCtl”));

TRANSFERINDEX = U.objectFieldOffset

(k.getDeclaredField(“transferIndex”));

BASECOUNT = U.objectFieldOffset

(k.getDeclaredField(“baseCount”));

CELLSBUSY = U.objectFieldOffset

(k.getDeclaredField(“cellsBusy”));

Class<?> ck = CounterCell.class;

CELLVALUE = U.objectFieldOffset

(ck.getDeclaredField(“value”));

Class<?> ak = Node[].class;

ABASE = U.arrayBaseOffset(ak);

int scale = U.arrayIndexScale(ak);

if ((scale & (scale - 1)) != 0)

throw new Error(“data type scale not a power of two”);

ASHIFT = 31 - Integer.numberOfLeadingZeros(scale);

} catch (Exception e) {

throw new Error(e);

}

}

三个核心方法

ConcurrentHashMap定义了三个原子操作,用于对指定位置的节点进行操作。正是这些原子操作保证了ConcurrentHashMap的线程安全。

@SuppressWarnings(“unchecked”)

//获得在i位置上的Node节点

static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {

return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);

}

  • 利用CAS算法设置i位置上的Node节点。之所以能实现并发是因为他指定了原来这个节点的值是多少

  • 在CAS算法中,会比较内存中的值与你指定的这个值是否相等,如果相等才接受你的修改,否则拒绝你的修改

  • 因此当前线程中的值并不是最新的值,这种修改可能会覆盖掉其他线程的修改结果 有点类似于SVN

static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,

Node<K,V> c, Node<K,V> v) {

return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);

}

//利用volatile方法设置节点位置的值

static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v) {

U.putObjectVolatile(tab, ((long)i << ASHIFT) + ABASE, v);

}

初始化方法initTable


  • 对于ConcurrentHashMap来说,调用它的构造方法仅仅是设置一些参数。

  • 整个table的初始化是在向ConcurrentHashMap中插入元素的时候发生的。

  • 如调用put、computeIfAbsent、compute、merge等方法的时候,调用时机是检查table==null。

  1. 初始化方法主要应用了关键属性sizeCtl 如果这个值 <0,表示其他线程正在进行初始化,就放弃这个操作。

  2. 在这也可以看出ConcurrentHashMap的初始化只能由一个线程完成。

  3. 如果获得了初始化权限,就用CAS方法将sizeCtl置为-1,防止其他线程进入。

初始化数组后,将sizeCtl的值改为0.75 * n,源码如下:

/**

  • Initializes table, using the size recorded in sizeCtl.

*/

private final Node<K,V>[] initTable() {

Node<K,V>[] tab; int sc;

while ((tab = table) == null || tab.length == 0) {

//sizeCtl表示有其他线程正在进行初始化操作,把线程挂起。对于table的初始化工作,只能有一个线程在进行。

if ((sc = sizeCtl) < 0)

Thread.yield(); // lost initialization race; just spin

else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {//利用CAS方法把sizectl的值置为-1 表示本线程正在进行初始化

try {

if ((tab = table) == null || tab.length == 0) {

int n = (sc > 0) ? sc : DEFAULT_CAPACITY;

@SuppressWarnings(“unchecked”)

Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];

table = tab = nt;

sc = n - (n >>> 2);//相当于0.75*n 设置一个扩容的阈值

}

} finally {

sizeCtl = sc;

}

break;

}

}

return tab;

}

扩容方法 transfer

  • ConcurrentHashMap容量不足的时候,需要对table进行扩容。

  • 这个方法的基本思想跟HashMap是很像的,但是由于它是支持并发扩容的,所以要复杂的多。

  • 原因是它支持多线程进行扩容操作,而并没有加锁。

  • 我想这样做的目的不仅仅是为了满足concurrent的要求,而是希望利用并发处理去减少扩容带来的时间影响。因为在扩容的时候,总是会涉及到从一个“数组”到另一个“数组”拷贝的操作,如果这个操作能够并发进行,那真真是极好的了。

整个扩容操作分为两个部分
  • 第一部分是构建一个nextTable,它的容量是原来的两倍,这个操作是单线程完成的。这个单线程的保证是通过RESIZE_STAMP_SHIFT这个常量经过一次运算来保证的,这个地方在后面会有提到;

  • 第二个部分就是将原来table中的元素复制到nextTable中,这里允许多线程进行操作

先来看一下单线程是如何完成的:
  • 它的大体思想就是遍历、复制的过程。首先根据运算得到需要遍历的次数i,然后利用tabAt方法获得i位置的元素:

  • 如果这个位置为空,就在原table中的i位置放入forwardNode节点,这个也是触发并发扩容的关键点

  • 如果这个位置是Node节点(fh>=0),如果它是一个链表的头节点,就构造一个反序链表,把他们分别放在nextTable的i和i+n的位置上

  • 如果这个位置是TreeBin节点(fh<0),也做一个反序处理,并且判断是否需要untreefi,把处理的结果分别放在nextTable的i和i+n的位置上

  • 遍历过所有的节点以后就完成了复制工作,这时让nextTable作为新的table,并且更新sizeCtl为新容量的0.75倍 ,完成扩容

再看一下多线程是如何完成的:
  • 如果遍历到的节点是forward节点,就向后继续遍历,再加上给节点上锁的机制,就完成了多线程的控制。

  • 多线程遍历节点,处理了一个节点,就把对应点的值set为forward,另一个线程看到forward,就向后遍历。

  • 这样交叉就完成了复制工作。而且还很好的解决了线程安全的问题。 这个方法的设计实在是让我膜拜。

img

/**

  • 一个过渡的table表,只有在扩容的时候才会使用

*/

private transient volatile Node<K,V>[] nextTable;

/**

  • Moves and/or copies the nodes in each bin to new table. See

  • above for explanation.

*/

private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {

int n = tab.length, 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];//构造一个nextTable对象 它的容量是原来的两倍

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;//并发扩容的关键属性 如果等于true 说明这个节点已经处理过

boolean finishing = false; // to ensure sweep before committing nextTab

for (int i = 0, bound = 0;😉 {

Node<K,V> f; int fh;

//这个while循环体的作用就是在控制i-- 通过i–可以依次遍历原hash表中的节点

while (advance) {

int nextIndex, nextBound;

if (–i >= bound || finishing)

advance = false;

else if ((nextIndex = transferIndex) <= 0) {

i = -1;

advance = false;

}

else if (U.compareAndSwapInt

(this, TRANSFERINDEX, nextIndex,

nextBound = (nextIndex > stride ?

nextIndex - stride : 0))) {

bound = nextBound;

i = nextIndex - 1;

advance = false;

}

}

if (i < 0 || i >= n || i + n >= nextn) {

int sc;

if (finishing) {

//如果所有的节点都已经完成复制工作 就把nextTable赋值给table 清空临时对象nextTable

nextTable = null;

table = nextTab;

sizeCtl = (n << 1) - (n >>> 1);//扩容阈值设置为原来容量的1.5倍 依然相当于现在容量的0.75倍

return;

}

//利用CAS方法更新这个扩容阈值,在这里面sizectl值减一,说明新加入一个线程参与到扩容操作

if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {

if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)

return;

finishing = advance = true;

i = n; // recheck before commit

}

}

//如果遍历到的节点为空 则放入ForwardingNode指针

else if ((f = tabAt(tab, i)) == null)

advance = casTabAt(tab, i, null, fwd);

//如果遍历到ForwardingNode节点 说明这个点已经被处理过了 直接跳过 这里是控制并发扩容的核心

else if ((fh = f.hash) == MOVED)

advance = true; // already processed

else {

//节点上锁

synchronized (f) {

if (tabAt(tab, i) == f) {

Node<K,V> ln, hn;

//如果fh>=0 证明这是一个Node节点

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;

}

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);

}

//在nextTable的i位置上插入一个链表

setTabAt(nextTab, i, ln);

//在nextTable的i+n的位置上插入另一个链表

setTabAt(nextTab, i + n, hn);

//在table的i位置上插入forwardNode节点 表示已经处理过该节点

setTabAt(tab, i, fwd);

//设置advance为true 返回到上面的while循环中 就可以执行i–操作

advance = true;

}

//对TreeBin对象进行处理 与上面的过程类似

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;

}

}

//如果扩容后已经不再需要tree的结构 反向转换为链表结构

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;

//在nextTable的i位置上插入一个链表

setTabAt(nextTab, i, ln);

//在nextTable的i+n的位置上插入另一个链表

setTabAt(nextTab, i + n, hn);

//在table的i位置上插入forwardNode节点 表示已经处理过该节点

setTabAt(tab, i, fwd);

//设置advance为true 返回到上面的while循环中 就可以执行i–操作

advance = true;

}

}

}

}

}

}

put方法

ConcurrentHashMap最常用的就是put和get两个方法

  1. 根据hash值计算这个新插入的点在table中的位置i,如果i位置是空的,直接放进去,否则进行判断,如果i位置是树节点,按照树的方式插入新的节点,否则把i插入到链表的末尾。

  2. ConcurrentHashMap中依然沿用这个思想,有一个最重要的不同点就是ConcurrentHashMap不允许key或value为null值

  • 另外由于涉及到多线程,put方法就要复杂一点。在多线程中可能有以下两个情况
  1. 如果一个或多个线程正在对ConcurrentHashMap进行扩容操作,当前线程也要进入扩容的操作中
  • 这个扩容的操作之所以能被检测到,是因为transfer方法中在空结点上插入forward节点,如果检测到需要插入的位置被forward节点占有,就帮助进行扩容;

  • 如果检测到要插入的节点是非空且不是forward节点,就对这个节点加锁,这样就保证了线程安全。尽管这个有一些影响效率,但是还是会比hashTable的synchronized要好得多。

  • 整体流程就是首先定义不允许key或value为null的情况放入,对于每一个放入的值,首先利用spread方法对key的hashcode进行一次hash计算,由此来确定这个值在table中的位置

如果这个位置是空的,那么直接放入,而且不需要加锁操作。

如果这个位置存在结点,说明发生了hash碰撞,首先判断这个节点的类型。

如果是链表节点(fh>0),则得到的结点就是hash值相同的节点组成的链表的头节点。

需要依次向后遍历确定这个新加入的值所在位置。

如果遇到hash值与key值都与新加入节点是一致的情况,则只需要更新value值即可。否则依次向后遍历,直到链表尾插入这个结点。 如果加入这个节点以后链表长度大于8,就把这个链表转换成红黑树。如果这个节点的类型已经是树节点的话,直接调用树节点的插入方法进行插入新的值。

public V put(K key, V value) {

return putVal(key, value, false);

}

/** Implementation for put and putIfAbsent */

final V putVal(K key, V value, boolean onlyIfAbsent) {

//不允许 key或value为null

if (key == null || value == null) throw new NullPointerException();

//计算hash值

int hash = spread(key.hashCode());

int binCount = 0;

//死循环 何时插入成功 何时跳出

for (Node<K,V>[] tab = table;😉 {

Node<K,V> f; int n, i, fh;

//如果table为空的话,初始化table

if (tab == null || (n = tab.length) == 0)

tab = initTable();

//根据hash值计算出在table里面的位置

else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {

//如果这个位置没有值 ,直接放进去,不需要加锁

if (casTabAt(tab, i, null,

new Node<K,V>(hash, key, value, null)))

break; // no lock when adding to empty bin

}

//当遇到表连接点时,需要进行整合表的操作

else if ((fh = f.hash) == MOVED)

tab = helpTransfer(tab, f);

else {

V oldVal = null;

//结点上锁 这里的结点可以理解为hash值相同组成的链表的头结点

synchronized (f) {

if (tabAt(tab, i) == f) {

//fh〉0 说明这个节点是一个链表的节点 不是树的节点

if (fh >= 0) {

binCount = 1;

//在这里遍历链表所有的结点

for (Node<K,V> e = f;; ++binCount) {

K ek;

//如果hash值和key值相同 则修改对应结点的value值

if (e.hash == hash &&

((ek = e.key) == key ||

(ek != null && key.equals(ek)))) {

oldVal = e.val;

if (!onlyIfAbsent)

e.val = value;

break;

}

Node<K,V> pred = e;

//如果遍历到了最后一个结点,那么就证明新的节点需要插入 就把它插入在链表尾部

if ((e = e.next) == null) {

pred.next = new Node<K,V>(hash, key,

value, null);

break;

}

}

}

//如果这个节点是树节点,就按照树的方式插入值

else if (f instanceof TreeBin) {

Node<K,V> p;

binCount = 2;

if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,

value)) != null) {

oldVal = p.val;

if (!onlyIfAbsent)

p.val = value;

}

}

}

}

if (binCount != 0) {

//如果链表长度已经达到临界值8 就需要把链表转换为树结构

if (binCount >= TREEIFY_THRESHOLD)

treeifyBin(tab, i);

if (oldVal != null)

return oldVal;

break;

}

}

}

//将当前ConcurrentHashMap的元素数量+1

addCount(1L, binCount);

return null;

}

helpTransfer方法

这是一个协助扩容的方法。这个方法被调用的时候,当前ConcurrentHashMap一定已经有了nextTable对象,首先拿到这个nextTable对象,调用transfer方法。回看上面的transfer方法可以看到,当本线程进入扩容方法的时候会直接进入复制阶段。

/**

  • Helps transfer if a resize is in progress.

*/

final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) {

Node<K,V>[] nextTab; int sc;

if (tab != null && (f instanceof ForwardingNode) &&

(nextTab = ((ForwardingNode<K,V>)f).nextTable) != null) {

int rs = resizeStamp(tab.length);//计算一个操作校验码

while (nextTab == nextTable && table == tab &&

(sc = sizeCtl) < 0) {

if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||

sc == rs + MAX_RESIZERS || transferIndex <= 0)

break;

if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) {

transfer(tab, nextTab);

break;

}

}

return nextTab;

}

return table;

}

treeifyBin方法

这个方法用于将过长的链表转换为TreeBin对象。但是他并不是直接转换,而是进行一次容量判断,如果容量没有达到转换的要求,直接进行扩容操作并返回;如果满足条件才链表的结构抓换为TreeBin ,这与HashMap不同的是,它并没有把TreeNode直接放入红黑树,而是利用了TreeBin这个小容器来封装所有的TreeNode.

private final void treeifyBin(Node<K,V>[] tab, int index) {

Node<K,V> b; int n, sc;

if (tab != null) {

if ((n = tab.length) < MIN_TREEIFY_CAPACITY)//如果table.length<64 就扩大一倍 返回

tryPresize(n << 1);

else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {

synchronized (b) {

if (tabAt(tab, index) == b) {

TreeNode<K,V> hd = null, tl = null;

//构造了一个TreeBin对象 把所有Node节点包装成TreeNode放进去

for (Node<K,V> e = b; e != null; e = e.next) {

TreeNode<K,V> p =

new TreeNode<K,V>(e.hash, e.key, e.val,

null, null);//这里只是利用了TreeNode封装 而没有利用TreeNode的next域和parent域

if ((p.prev = tl) == null)

hd = p;

else

tl.next = p;

tl = p;

}

//在原来index的位置 用TreeBin替换掉原来的Node对象

setTabAt(tab, index, new TreeBin<K,V>(hd));

}

}

}

}

}

get方法

get方法比较简单,给定一个key来确定value的时候,必须满足两个条件 key相同 hash值相同,对于节点可能在链表或树上的情况,需要分别去查找.

public V get(Object key) {

Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;

//计算hash值

int h = spread(key.hashCode());

//根据hash值确定节点位置

if ((tab = table) != null && (n = tab.length) > 0 &&

(e = tabAt(tab, (n - 1) & h)) != null) {

完结

Redis基于内存,常用作于缓存的一种技术,并且Redis存储的方式是以key-value的形式。Redis是如今互联网技术架构中,使用最广泛的缓存,在工作中常常会使用到。Redis也是中高级后端工程师技术面试中,面试官最喜欢问的问题之一,因此作为Java开发者,Redis是我们必须要掌握的。

Redis 是 NoSQL 数据库领域的佼佼者,如果你需要了解 Redis 是如何实现高并发、海量数据存储的,那么这份腾讯专家手敲《Redis源码日志笔记》将会是你的最佳选择。

本文已被CODING开源项目:【一线大厂Java面试题解析+核心总结学习笔记+最新讲解视频+实战项目源码】收录

需要这份系统化的资料的朋友,可以点击这里获取

用了TreeNode封装 而没有利用TreeNode的next域和parent域

if ((p.prev = tl) == null)

hd = p;

else

tl.next = p;

tl = p;

}

//在原来index的位置 用TreeBin替换掉原来的Node对象

setTabAt(tab, index, new TreeBin<K,V>(hd));

}

}

}

}

}

get方法

get方法比较简单,给定一个key来确定value的时候,必须满足两个条件 key相同 hash值相同,对于节点可能在链表或树上的情况,需要分别去查找.

public V get(Object key) {

Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;

//计算hash值

int h = spread(key.hashCode());

//根据hash值确定节点位置

if ((tab = table) != null && (n = tab.length) > 0 &&

(e = tabAt(tab, (n - 1) & h)) != null) {

完结

Redis基于内存,常用作于缓存的一种技术,并且Redis存储的方式是以key-value的形式。Redis是如今互联网技术架构中,使用最广泛的缓存,在工作中常常会使用到。Redis也是中高级后端工程师技术面试中,面试官最喜欢问的问题之一,因此作为Java开发者,Redis是我们必须要掌握的。

Redis 是 NoSQL 数据库领域的佼佼者,如果你需要了解 Redis 是如何实现高并发、海量数据存储的,那么这份腾讯专家手敲《Redis源码日志笔记》将会是你的最佳选择。

[外链图片转存中…(img-H08UC3IJ-1715478787338)]

本文已被CODING开源项目:【一线大厂Java面试题解析+核心总结学习笔记+最新讲解视频+实战项目源码】收录

需要这份系统化的资料的朋友,可以点击这里获取

  • 12
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值