[java] ConcurrentHashMap

Java集合之ConcurrentHashMap(含源码 超详细) Java7 中 ConcruuentHashMap 使用的分段锁,也就是每一个 Segment 上同时只有一个线程可以操作,每一个 Segment 都是一个类似 HashMap 数组的结构,它可以扩容,它的冲突会转化为链表。但是 Segment 的个数一但初始化就不能改变。Java8 中的 ConcruuentHashMap 使用的 Synchronized 锁加 CAS 的机制。结构也由 Java7 中的Segment 数组 + HashEntry 数组 + 链表进化成了。 阅读详情

在分析ConcurrentHashMap的实现原理之前,先来介绍一下hashmap以及hashtable的特点和可能存在的问题.

TreeMap

//todo

HashTable

hashTable,它是线程安全的,它在所有涉及到多线程操作的都加上了synchronized关键字来锁住整个table,这就意味着所有的线程都在竞争一把锁,在多线程的环境下,它是安全的,但是无疑是效率低下的,尤其是在过去synchronized比较重的时候。

但是在jdk1.5之后,synchronized关键字的效率得到了很大提升,因此不确定HashTable的效率是否还像以前那样所说的低下。

ConcurrentHashMap

jdk1.7 基于分段锁

在jdk1.7之中,ConcurrentHashMap使用的是分段锁技术实现,把HashMap分割成若干个Segment,在put的时候需要锁住Segment,get时候不加锁,使用volatile来保证可见性。在concurrentHashMap中存放数据的是一个Node<K,V>的内部类,而存放所有数据的就是这些Node组合起来的一个table,它的定义如下:

/**
     * The array of bins. Lazily initialized upon first insertion.
     * Size is always a power of two. Accessed directly by iterators.
     */
    transient volatile Node<K,V>[] table;

使用了volatile修饰,保证可见性。

当要统计全局时(比如size),首先会尝试多次计算modcount来确定,这几次尝试中,是否有其他线程进行了修改操作,如果没有,则直接返回size。如果有,则需要依次锁住所有的Segment来计算。下图是ConcurrentHashMap的一个结构图:

jdk1.8 基于CAS

jdk7中ConcurrentHashmap中,当长度过长碰撞会很频繁,链表的增改删查操作都会消耗很长的时间,影响性能,所以jdk8 中完全重写了concurrentHashmap,代码量从原来的1000多行变成了 6000多 行,实现上也和原来的分段式存储有很大的区别。在jdk1.8之中,完全摒弃了segment的实现,而是借鉴了Hashmap的实现,也采用了数组加链表加红黑树的实现,并且也是在链表长度大于8时转换为红黑树。其结构大致如下:

put时不是锁住segment而是锁住一个具体的node,减小锁粒度,提高了效率。除此之外,put时使用的一个重要的思想就是CAS思想. put方法的源代码如下:

 /** Implementation for put and putIfAbsent */
    final V putVal(K key, V value, boolean onlyIfAbsent) {
        if (key == null || value == null) throw new NullPointerException();
        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)
                tab = initTable();
            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;
                synchronized (f) {
                    if (tabAt(tab, i) == f) {
                        if (fh >= 0) {
                            binCount = 1;
                            for (Node<K,V> e = f;; ++binCount) {
                                K ek;
                                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) {
                    if (binCount >= TREEIFY_THRESHOLD)
                        treeifyBin(tab, i);
                    if (oldVal != null)
                        return oldVal;
                    break;
                }
            }
        }
        addCount(1L, binCount);
        return null;
    }

在for循环之前,他会根据key去计算一个int类型的hash值,计算的方式如下:

  static final int spread(int h) {
        return (h ^ (h >>> 16)) & HASH_BITS;
    }

最关键的逻辑在for循环遍历整个table里面,有一个if,两个else if,一个else,一共四个处理分支我们一个一个来看。

第一个if的判断条件是: if (tab == null || (n = tab.length) == 0). tab就是指的意思是当前遍历的这个table,如果当前这个table为空或者这个table的长度是0,那么就先初始化这个table。初始化table调用的方法为initTable,代码如下:

 /**
     * 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) {
            if ((sc = sizeCtl) < 0)
                Thread.yield(); // lost initialization race; just spin
            else if (U.compareAndSwapInt(this, SIZECTL, sc, -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);
                    }
                } finally {
                    sizeCtl = sc;
                }
                break;
            }
        }
        return tab;
    }

这里是使用了CAS来保证只有一个线程能够实例化成功table. 他的实现调用了来自于类sun.misc.Unsafe. 在这个Unsafe类中它是一个native方法。关于CAS的内容请参见另一篇博客。

第二个else if 的判断条件是: else if ((f = tabAt(tab, i = (n - 1) & hash)) == null). 

注意,f已经在此处复制。这里的hash是一个int类型的值,它事实上就是put方法的参数key,更准确的说是是根据key计算出的hash值,这里判断的是根据这个key的hash值去当前table中找对应的元素,若找到的为空,则证明当前key在map里面是没有映射的。根据hash值去当前table中找元素的方法是tabAt,它的定义如下:

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

这里获取数组中对应元素使用的方法也是来组Unsafe类的方法getObjectVolatile, 我们应该都知道,在数组中知道下标之后,可以直接使用table[index]就能获得对应的元素。那这里为什么要这么做呢?并且,该table已经是被volatile修饰了,这意味着它的任何改动应该都是立刻可的值得。暂且先放过这个问题留待之后回答。

那么现在就直接插入吗?这样想就错了,因为我们必须考虑并发,在这里若判断成立,则说明当前位置为空是第一次插入元素(这里在jdk源代码里的注释很有意思:// no lock when adding to empty bin),它则接着调用了一个方法:casTabAt(),这个方法的定义如下,这个方法的实现也是使用CAS的方式来进行put,与上面其实是一样的。使用了compareAndSwapObject方法插入Node节点。

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

若CAS成功,则说明Node插入成功,则break,然后调用addCount(1L,binCount)方法检查当前容量是否需要扩容

若CAS失败,说明在这个插入之前其他线程提前插入了节点,则自旋重新尝试在这个位置插入节点。问题:这个自旋锁是如何实现的?目前暂时没有看到解释。

第三个else if 的判断条件是:((fh = f.hash) == MOVED) 

注意,fh变量已经在此处赋值。MOVED的定义如下:

 static final int MOVED     = -1; // hash for forwarding nodes

这里的意思是若f的hash值为-1,说明当前f是一个ForwadingNode节点,有其他线程正在扩容,则一起进行扩容,调用方法helpTransfer

第四个else 就是除了上面三个判断条件的其他所有情况

其余情况把新的Node节点按链表或红黑树的方式插入到合适的位置,这个过程采用同步内置锁实现并发。具体代码如下:

 V oldVal = null;
                synchronized (f) {
                    if (tabAt(tab, i) == f) {
                        if (fh >= 0) {
                            binCount = 1;
                            for (Node<K,V> e = f;; ++binCount) {
                                K ek;
                                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) {
                    if (binCount >= TREEIFY_THRESHOLD)
                        treeifyBin(tab, i);
                    if (oldVal != null)
                        return oldVal;
                    break;
                }
            }

可以看到在这里面首先是对f加上了同步锁(注意,f在第二个判断时,若不为null,则已经进行赋值),然后又分了两种情况,第一是普通链表时的put,第二是红黑树的put操作(若f是TreeBin类型节点,说明f是红黑树的节点)。在put操作里会根据是否已经有值决定是替换还是新增一个Node。

最后在put插座结束之后,判断binCount >= TREEIFY_THRESHOLD,即当节点数大于等于8时,就执行treeifyBin方法,把链表转换为红黑数结构。代码如下:

 /**
     * Replaces all linked nodes in bin at given index unless table is
     * too small, in which case resizes instead.
     */
    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)
                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;
                        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);
                            if ((p.prev = tl) == null)
                                hd = p;
                            else
                                tl.next = p;
                            tl = p;
                        }
                        setTabAt(tab, index, new TreeBin<K,V>(hd));
                    }
                }
            }
        }
    }

关于putIfAbsent方法,他的实现和put是一样的,只是如果(调用该方法时)key-value 已经存在,则返回那个 value 值。如果调用时 map 里没有找到 key 的 mapping,返回一个 null 值

扩容

当table容量不足的时候,即table的元素数量达到容量阈值sizeCtl,需要对table进行扩容。
整个扩容分为两部分:

  1. 构建一个nextTable,大小为table的两倍。
  2. 把table的数据复制到nextTable中。

代码大致如下:

private final void addCount(long x, int check) {
    ...
    if (check >= 0) {
        Node<K,V>[] tab, nt; int n, sc;
        while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
               (n = tab.length) < MAXIMUM_CAPACITY) {
            int rs = resizeStamp(n);
            if (sc < 0) {
                if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
                    sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
                    transferIndex <= 0)
                    break;
                if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
                    transfer(tab, nt);
            }
            else if (U.compareAndSwapInt(this, SIZECTL, sc,
                                         (rs << RESIZE_STAMP_SHIFT) + 2))
                transfer(tab, null);
            s = sumCount();
        }
    }
}

图片来源于:https://segmentfault.com/p/1210000010020931/read

 

Java中的ConcurrentHashMap的使用与原理 ConcurrentHashMap 是 Java 中为‌高并发场景‌设计的线程安全哈希表实现,位于 java.util.concurrent 包。它解决了 Hashtable 和 Collections.synchronizedMap() 的锁粒度过大导致的性能问题,在保证线程安全的同时显著提升并发吞吐量。ConcurrentHashMap 是 Java 中线程安全的哈希表实现,其核心原理在于通过精细化锁机制(分段锁或 CAS + synchronized)实现高并发性能。 阅读详情

相关推荐

M2DGR数据集在一些SLAM框架上的配置与运行:ORB-SLAM系列、VINS-Mono、LOAM系列、FAST-LIO系列、hdl_graph_slam

为了说明以上这些SLAM框架的测试与数据集适配,以M2DGR数据集为例进行各自的配置与运行,方便我以后适配自己的数据集,运行的SLAM框架包括:ORB-SLAM2、ORB-SLAM3、VINS-Mono、A-LOAM、LeGO-LOAM、LIO-SAM、LVI-SAM、FAST-LIO2、FAST-LIVO、Faster-LIO、hdl_graph_slam。实验基于Ubuntu20.04,其中ORB、VINS-Mono、和LOAM系列的安装参考我以前的博客,有详细的安装方法,其他的框架在后面会简单介绍一下

zardforever123的博客 1万+

ConcurrentHashMap详解

文章目录spread()方法putVal()方法initTable()方法tryPresize()tableSizeFor()resizeStamp()transfer()总结compute()addCount() 计算成员数量sumCount()fullAddCount()资源锁sizeCtl其它节点类型forwarding节点ReservationNode节点 spread()方法 作用:用于获取槽位值 static final int spread(int h) { //h为k

唐芬奇的博客 8万+

第14.5章 高翔博士最新开源框架Lightning-LM源码解读(五):揭秘位姿图优化核心模块,从数学原理到代码实现

本文介绍了Lightning-LM框架中的位姿图优化(PGO)模块实现。该模块通过融合多源定位信息(激光定位、激光里程计、DR等),构建并优化位姿图来获得更精确的位姿估计。 主要内容包括: 模块概况与数据流程:初始化配置→添加PGO帧→分配位姿→优化求解→更新位姿→滑动窗口 16个核心函数详解:从构造函数到边缘化处理,涵盖完整的PGO工作流程 关键技术点:噪声参数设置、优化问题构建、约束添加、异常值处理等 关键数据结构:帧队列管理、顶点和边构建、优化统计信息收集 该PGO模块采用增量式优化方式,支持多种约束

行知SLAM 1216

Map 综述(三):彻头彻尾理解 ConcurrentHashMap

ConcurrentHashMap是J.U.C的重要成员,它是HashMap的一个线程安全的版本。在默认理想状态下,ConcurrentHashMap可以支持16个线程执行并发写操作及任意数量线程的读操作。本文将结合Java内存模型和JDK源代码,剖析其高并发的具体实现机制,包括在JDK中的定义和结构、并发存取、重哈希和跨段操作,并着重剖析了ConcurrentHashMap读操作不需要加锁的内在奥秘和原理。

Rico's Blogs 14万+

鸿蒙开发实战项目(九十二):HUAWEI Health血压测量

是华为开发的用于运动健康领域的一个开放平台, 三方APP可以借助HUAWEI Health开放平台提供的。

斌擎科技 1132

PyTorch实战(19)——变分自编码器(Variational Autoencoder,VAE)

变分自编码器 (Variational Autoencoder, VAE) 是一种结合了自编码器和概率建模的生成模型,通过编码器将输入数据映射到潜在空间中的概率分布,并通过解码器将从潜在空间采样得到的潜在变量映射回原始数据空间,实现了数据的生成和特征学习。本节中,将介绍变分自编码器的基本概念,并使用 PyTorch 实现变分自编码器生成新图像。

盼小辉丶的博客 2889

ConcurrentHashMap

引言 HashMap 在我们日常的开发中使用频率最高的一个工具类之一,然而使用 HashMap 最大的问题之一就是它是线程不安全的,如果我们想要线程安全, 这时候就可以选择使用 ConcurrentHashMap,ConcurrentHashMap 和 HashMap 的功能是基本一样的,ConcurrentHashMap 是 HashMap 的线程安全版本。 因 ConcurrentHashMap 和 HashMap 在 jdk1.8 版本中排除线程的安全性方面,其他的设计都很类似。 Concurrent

weixin_46574815的博客 1万+

基于STM32L4XX、HAL库的CH182H1以太网收发器(PHY)驱动程序设计

define PHY_REG_PHYSCR 0x12 // PHY特殊控制寄存器。#define PHY_REG_PHYSTS 0x10 // PHY状态寄存器。#define PHY_REG_PHYIDR 0x11 // PHY标识寄存器。#define PHY_REG_ID1 0x02 // PHY标识符1。#define PHY_REG_ID2 0x03 // PHY标识符2。

zhusl6688的博客 404

深入浅出ConcurrentHashMap详解

JDK7 版本的 ConcurrentHashMap 简单介绍,以及 JDK8 的改动

学哥斌的博客 8万+

ONVIF协议简介与协议测试工具使用详解

Onvif协议及协议测试工具使用详解

dvlinker的技术专栏 3万+

50、压电微泵与技术设备驱动的研究分析

本文研究了压电微泵在弹性微通道中的流体流动特性及技术设备驱动的振动行为。通过建立轴对称流固耦合模型,利用FreeFem++进行数值模拟,分析了不同数量压电致动器和频率对微泵流量的影响,发现多个致动器按特定时序压缩可产生净正向流量,并提出了稳态流量的近似解析公式。同时,针对技术设备驱动系统,探讨了周期性负载引起的强迫振动特性,明确了共振条件及影响因素,并比较了主动、被动和优化设计等振动控制策略。最后展望了实验验证、多物理场耦合与智能控制等未来研究方向,为相关设备的优化设计与高效运行提供了理论支持。

convnet3designer的博客 148

【Java集合三】ConcurrentHashMap 详解

本文深入解析了 Java 中的 ConcurrentHashMap,对比了 Java 7 和 Java 8 的版本差异,重点介绍了其线程安全机制、数据结构、扩容机制以及 put 和 get 操作的原理。

叉烧钵钵鸡的博客 1055

PSINS工具箱学习(四)捷联惯导更新算法

原始 Markdown文档、Visio流程图、XMind思维导图见:https://github.com/LiZhengXiao99/Navigation-Learning。

实践出真理,接毕设/课设项目开发指导 607

Java ConcurrentHashMap - 详解

本文介绍了Java中的ConcurrentHashMap(CHM),重点分析了其线程安全机制和性能优化。CHM通过分段锁(Java7)和CAS+synchronized(Java8)技术实现高并发访问,相比Hashtable显著提升了性能。Java8版本将锁粒度细化到单个桶,支持函数式API,并优化了数据结构(数组+链表/红黑树)。文章还详细说明了CHM的关键特性(弱一致性迭代器、禁止null值等)、常用原子操作方法(putIfAbsent、compute等)以及适用场景(高并发缓存)。正确使用CHM需要注

M_Reus_11的博客 908

怀旧版电脑GBC模拟器

再现原版GBC掌机的界面,电脑运行连游戏界面都一样大小,很怀旧,很真实,(英文版)强烈推荐,很小的文件,很难才找到的。

efm32 入门.pdf

本PDF共计22页,介绍的是操作驱动API,下载与否自己斟酌。

Java中的ConcurrentHashMap

ConcurrentHashMap 是 Java 并发包(java.util.concurrent)中​​线程安全的哈希表实现​​,专为高并发场景设计,在保证线程安全的同时显著提升性能。

moonNife的专栏 994

LinePhone3.2.7(android端最新版本) sip客户端

SIP客户端For android:LinePhone3.2.7(最新版本),官网地址:http://www.linphone.org/

ConcurrentHashMap 从Java7 到 Java8的改变

一、关于分段锁 1.分段锁发展概况 集合框架很大程度减少了java程序员的重复劳动。在Java多线程环境中,以线程安全的方式使用集合类是一个首先考虑的问题。 能够保证线程安全的哈希表中,ConcurrentHashMap是大家都熟知的,也知道它内部使用了分段锁。然而,进入到Java8时代,分段锁成为了历史。 2.新版本ConcurrentHashMap 在Java8的Concurrent...

artaganan8的博客 436

RU DOS版、EFI版32bit/64bit BIOS常用工具

DOS版、EFI版的RU工具,可用来查看IO、MEM、PCI、SMBIOS等资源。亲测在EFI SHELL下可用。

【Java 基础】ConcurrentHashMap 底层原理

ConcurrentHashMap 是线程安全的哈希表,它是 Java 并发包中提供的一种高效的并发 Map 实现。ConcurrentHashMap 底层采用了分段锁的机制,不同的段(Segment)可以被不同的线程同时访问,从而提高了并发性能。

激流丶的博客 5642

Java集合:ConcurrentHashMap详解

根据key的hashcode重新计算hash值(主要是为了减少hash冲突),通过segmentFor方法定位到具体的哪个segment,然后调用segment的get方法。segmentFor方法是用来定位到具体的segment的,主要是通过使用hash值的高位与掩码进行位运算,segmentShift和segmentMask是通过上文初始化方法计算而来。if (count!while (e!// recheck。

2401_84010744的博客 916

Java集合ConcurrentHashMap——针对实习面试

本文针对即将面试实习的同学,总结了Java集合ConcurrentHashMap相关的面试题及其知识点,希望对大家有所帮助😀

清酒伴风的博客 1001

【JAVA】concurrentHashMap和HashTable有什么区别

在Java的集合框架中,和HashTable都提供了线程安全的哈希表实现,用于在多线程环境中安全地存储和检索数据。然而,它们在实现方式、性能和功能上存在一些显著的区别,因此在选择使用时需要根据具体的需求和场景进行权衡。在并发编程中,选择适当的数据结构对于程序的性能和可维护性至关重要。通过分段锁和 CAS 操作等机制在高并发环境中表现出色,而HashTable由于全局锁的设计在高并发下性能相对较低。在现代Java应用中,通常推荐使用作为线程安全的哈希表实现。

AliceNo的博客 2010

java ConcurrentHashMap详解

java ConcurrentHashMap详解

寂夜了无痕的博客 5869

Java ConcurrentHashMap

Java7 中实现的 ConcurrentHashMap 说实话还是比较复杂的,Java8 对 ConcurrentHashMap 进行了比较大的改动。建议读者可以参考 Java8 中 HashMap 相对于 Java7 HashMap 的改动,对于 ConcurrentHashMap,Java8 也引入了红黑树。 说实话,Java8 ConcurrentHashMap 源码真心不简单,最难的在...

祁东握力的博客 549

Java8 ConcurrentHashMap实现原理

Java8ConcurrentHashMap实现原理,基本和Java8的HashMap相同,不同于他的是前者保证了线程的安全性,和Java7的ConcurrentHashMap区别在于,两者保证线程安全性的机理不同,Java7中采用了“分段”锁的概念,每一个分段都有一把锁,锁内存储的着数据,锁的个数在初始化之后不能扩容,Java7 ConcurrentHashMap原理 传送门 但是Java8摒弃了这种设计理念,和Java8一样采用数组+链表+红黑树的概念来实现,那么要保证是线程安全(synchron..

hu10131013的博客 15万+

Java7与Java8中的ConcurrentHashMap

JAVA7 Java7的ConcurrentHashMap里有多把锁,每一把锁用于其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效的提高并发访问效率呢。这就是“锁分离”技术。 ConcurrentHashMap是由Segment数组结构和HashEntry数组结构组成。Segment是一种可重入锁(继承了ReentrantLock),在Con...

weixin_30596023的博客 293
上一篇: [java] 停止线程
下一篇: [Java] Class类是一个怎样的存在
topEngineerr
博客等级 码龄11年 627粉丝 157原创
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

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

抵扣说明:

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

余额充值