HashMap实现原理及源码阅读

   日期:2020-05-05     浏览:103    评论:0    
核心提示:点击进入个人网站,阅读体验更佳面试题先来看看常问的面试题有哪些底层数据结构hash冲突解决1.7和1java

可访问个人网站进行阅读最新版本,精力有限无法多网站同步更新,更新只会在个人网站进行

文章目录

    • 面试题
    • 一、底层数据结构
      • 1.1 构造函数
    • 二、存取机制
      • 2.1 put(K key, V value)
        • 2.1.1 hash()方法与hashcode()方法
        • 2.1.2 Fail-Fast 机制
      • 2.2 get(key)
      • 2.3 面试题
        • 2.3.1 hashcode()与equals()区别
        • 2.3.2 为什么要重写equals()方法?
        • 2.3.3 为什么改写了equals(),也需要改写hashcode()
        • 2.3.4 为什么改写了hashcode(),也需要改写equals()
    • 三、扩容机制
      • 3.1 resize()
    • 四、面试题
      • 4.1 扩容为什么是2倍?
      • 4.2 为什么String, Interger这样的wrapper类适合作为键?
      • 4.3 线程不安全的原因
      • 4.4 你了解重新调整HashMap大小存在什么问题吗?

面试题

先来看看常问的面试题有哪些

  • 底层数据结构
  • hash冲突解决
  • 1.7和1.8区别
  • 扩容机制(为什么是2倍)
  • rehash过程
  • 红黑树的左右旋

一、底层数据结构

public class HashMap<k,v> extends AbstractMap<k,v> implements Map<k,v>, Cloneable, Serializable {  
    private static final long serialVersionUID = 362498820763181265L;  
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 初始容量16 
    static final int MAXIMUM_CAPACITY = 1 << 30;//最大容量 
    static final float DEFAULT_LOAD_FACTOR = 0.75f;//填充比,占满0.75进行resize
    static final int TREEIFY_THRESHOLD = 8; 	// 链表长度达到8时将链表转换为红黑树 
    static final int UNTREEIFY_THRESHOLD = 6;   // 树大小为6,就转回链表
    static final int MIN_TREEIFY_CAPACITY = 64;  
    transient Node<k,v>[] table;//存储元素的数组 
    transient Set<map.entry<k,v>> entrySet;  
    transient int size;//存放元素的个数 
    transient int modCount;//被修改的次数fast-fail机制 
    int threshold;//临界值 当实际大小(容量*填充比)超过临界值时,会进行扩容 
    final float loadFactor;//填充比

// 1.位桶数组
transient Node<k,v>[] table;//存储(位桶)的数组</k,v>

// 2.数组元素Node<K,V>实现了Entry接口
//Node是单向链表,它实现了Map.Entry接口 
static class Node<k,v> implements Map.Entry<k,v> {  
    final int hash;  
    final K key;  
    V value;  
    Node<k,v> next;  
    //构造函数Hash值 键 值 下一个节点 
    Node(int hash, K key, V value, Node<k,v> next) {  
        this.hash = hash;  
        this.key = key;  
        this.value = value;  
        this.next = next;  
    }  
   
    public final K getKey()        { return key; }  
    public final V getValue()      { return value; }  
    public final String toString() { return key + = + value; }  
   
    public final int hashCode() {  
        return Objects.hashCode(key) ^ Objects.hashCode(value);  
    }  
   
    public final V setValue(V newValue) {  
        V oldValue = value;  
        value = newValue;  
        return oldValue;  
    }  
    //判断两个node是否相等,若key和value都相等,返回true。可以与自身比较为true 
    public final boolean equals(Object o) {  
        if (o == this)  
            return true;  
        if (o instanceof Map.Entry) {  
            Map.Entry<!--?,?--> e = (Map.Entry<!--?,?-->)o;  
            if (Objects.equals(key, e.getKey()) &&  
                Objects.equals(value, e.getValue()))  
                return true;  
        }  
        return false;  
    }
}

// 3.红黑树
static final class TreeNode<k,v> extends LinkedHashMap.Entry<k,v> {  
    TreeNode<k,v> parent;  // 父节点 
    TreeNode<k,v> left; //左子树 
    TreeNode<k,v> right;//右子树 
    TreeNode<k,v> prev;    // needed to unlink next upon deletion 
    boolean red;    //颜色属性 
    TreeNode(int hash, K key, V val, Node<k,v> next) {  
        super(hash, key, val, next);  
    }  
   
    //返回当前节点的根节点 
    final TreeNode<k,v> root() {  
        for (TreeNode<k,v> r = this, p;;) {  
            if ((p = r.parent) == null)  
                return r;  
            r = p;  
        }  
    }
}

总结:1.7的hashmap是由位桶数组+链表组成,1.8之后的hashmap由位桶数组+链表+红黑树组成。其中数组指bucket数组,数组中的元素是实现了map.Entry<k,v>接口的Node<k,v>,每个Node<k,v>包含key,value,next指针,hash值。当put元素时会调用hashcode计算hash值,相同key而value不同的元素会发生哈希碰撞,采用拉链拉解决,将该元素插入到链表中。当TREEIFY_THRESHOLD>8时,会转化成红黑树。

1.1 构造函数

//构造函数1 
public HashMap(int initialCapacity, float loadFactor) {  
    //指定的初始容量非负 
    if (initialCapacity < 0)  
        throw new IllegalArgumentException(Illegal initial capacity:  +  
                                           initialCapacity);  
    //如果指定的初始容量大于最大容量,置为最大容量 
    if (initialCapacity > MAXIMUM_CAPACITY)  
        initialCapacity = MAXIMUM_CAPACITY;  
    //填充比为正 
    if (loadFactor <= 0 || Float.isNaN(loadFactor))  
        throw new IllegalArgumentException(Illegal load factor:  +  
                                           loadFactor);  
    this.loadFactor = loadFactor;  
    this.threshold = tableSizeFor(initialCapacity);//新的扩容临界值 
}  
   
//构造函数2 
public HashMap(int initialCapacity) {  
    this(initialCapacity, DEFAULT_LOAD_FACTOR);  
}  
   
//构造函数3 
public HashMap() {  
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted 
}  
   
//构造函数4用m的元素初始化散列映射 
public HashMap(Map<!--? extends K, ? extends V--> m) {  
    this.loadFactor = DEFAULT_LOAD_FACTOR;  
    putMapEntries(m, false);  
}

二、存取机制

在明白它是怎么取之前需要先明白是怎么存的

2.1 put(K key, V value)

public V put(K key, V value) {  
    return putVal(hash(key), key, value, false, true);  
}  
  
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,  
                   boolean evict) {  
    Node<K,V>[] tab;   
    Node<K,V> p;   
    int n, i;  
        if ((tab = table) == null || (n = tab.length) == 0)	// 空表,需要初始化
            n = (tab = resize()).length; // resize()不仅用来调整大小,还用来进行初始化配置 
    	
    	 
    	// (n-1)&hash相当于hash%(n-1)
        if ((p = tab[i = (n - 1) & hash]) == null)  
            tab[i] = newNode(hash, key, value, null);  
    	  
        else {  
            Node<K,V> e;   
        	K k;  
    		  
            if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))  
                e = p;  //已存在就替换新值
              
            else if (p instanceof TreeNode)  //按红黑树方式插入
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);  
            else {  // 遍历链表插入链尾
                for (int binCount = 0; ; ++binCount) {  
        			  
                    if ((e = p.next) == null) {  
                        p.next = newNode(hash, key, value, null);  
             		//如果冲突的节点数已经达到8个,看是否需要改变冲突节点的存储结构, 
            //treeifyBin首先判断当前hashMap的长度,如果不足64,只进行 
                        //resize,扩容table,如果达到64,那么将冲突的存储结构为红黑树 
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st 
                            treeifyBin(tab, hash);  
                        break;  
                    }  
        			  
                    if (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))))  
                        break;  
                    // 继续向后
                    p = e;  
                }  
            }  
    		  
            if (e != null) { // existing mapping for key,就是key的Value存在 
                V oldValue = e.value;  
                if (!onlyIfAbsent || oldValue == null)  
                    e.value = value;  
                afterNodeAccess(e);  
                return oldValue;//返回存在的Value值 
            }  
        }  
        ++modCount;  // 修改次数+1
     	  
        if (++size > threshold)  
            resize();//扩容两倍 
        afterNodeInsertion(evict);  
        return null;  
    }

下面简单说下添加键值对put(key,value)的过程:

  1. 判断位桶数组是否为空数组,是则通过resize初始化

  2. 通过hash(key)计算hash值判断该Node<k,v>应该插入的位置(不同的key可能有相同的hashcode)

  3. 如果该位置还没插入值,则直接插入;如果已存在值

    • 判断key是否相同,是:则用e记录该结点;
    • 否:则判断table[i]是否为树结点,
      • 是:则以红黑树的方式插入,用e记录;
      • 否:则遍历链表插入到链尾(如果长度>8转成红黑树);遇到已存该元素的情况下,用e记录,并退出
  4. 在上述步骤中,都有用e记录了数组中或链表或红黑树已存在该元素的信息。通过修改e来覆盖原值

  5. 判断加入结点后是否超过门限值,是否需要扩容

2.1.1 hash()方法与hashcode()方法

我们通过hash方法计算索引,得到数组中保存的位置

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

以看到HashMap中的hash算法是通过key的hashcode值与其hashcode右移16位后得到的值进行异或运算得到的,那么为什么不直接使用key.hashCode(),而要进行异或操作?我们知道hash的目的是为了得到进行索引,而hash是有可能冲突的,也就是不同的key得到了同样的hash值,这样就很容易产业碰撞,如何减少这种情况的发生呢,就通过上述的hash(Object key)算法将hashcode 与 hashcode的低16位做异或运算,混合了高位和低位得出的最终hash值,冲突的概率就小多了

2.1.2 Fail-Fast 机制

我们知道 java.util.HashMap 不是线程安全的,因此如果在使用迭代器的过程中有其他线程修改了map,那么将抛出ConcurrentModificationException,这就是所谓fail-fast策略。这一策略在源码中的实现是通过 modCount 域,modCount 顾名思义就是修改次数,对HashMap 内容的修改都将增加这个值,那么在迭代器初始化过程中会将这个值赋给迭代器的 expectedModCount。在迭代过程中,判断 modCount 跟 expectedModCount 是否相等,如果不相等就表示已经有其他线程修改了 Map:注意到 modCount 声明为 volatile,保证线程之间修改的可见性。

所以在这里和大家建议,当大家遍历那些非线程安全的数据结构时,尽量使用迭代器

2.2 get(key)

通过put过程,我们已经知道Node(k,v)是怎么保存到map中的,现在来看看怎么取

public V get(Object key) {  
    Node<K,V> e;  
    return (e = getNode(hash(key), key)) == null ? null : e.value;  
}  
  
final Node<K,V> getNode(int hash, Object key) {  
    Node<K,V>[] tab;//Entry对象数组 
    Node<K,V> first,e; //在tab数组中经过散列的第一个位置 
    int n;  
    K k;  
      
    //也就是说在一条链上的hash值相同的 
    if ((tab = table) != null && (n = tab.length) > 0 &&(first = tab[(n - 1) & hash]) != null) {  
          
        if (first.hash == hash && // always check first node 
            ((k = first.key) == key || (key != null && key.equals(k))))//判断条件是hash值要相同,key值要相同 
            return first;  
          
        if ((e = first.next) != null) {  
            if (first instanceof TreeNode)  
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);  
              
            do {  
                if (e.hash == hash &&  
                    ((k = e.key) == key || (key != null && key.equals(k))))  
                    return e;  
            } while ((e = e.next) != null);  
        }  
    }  
    return null;  
}
  1. 通过hash(key)找到bucket数组中该hash值的位置,判断该位置的元素也就是first的key是否与要找的这个key相同
    • 是:则返回该first元素
    • 否:判断first是否是树节点
      • 是:则通过红黑树的方式进行查找
      • 否:遍历链表查找到key相同的Node并返回
  2. 如果没找到,则返回null

2.3 面试题

2.3.1 hashcode()与equals()区别

get()查找元素的过程:计算key的hashcode,找到数组中对应位置的某一元素,然后通过key的equals方法在对应位置的链表中找到需要的元素。

Object的equals()是基于比较内存地址实现的,hashcode()是比较内存地址的hash值

在map中,hashcode(实际是hash方法,封装了hashcode和低16位异或运算)用来计算key应该放在数组中的哪个位置,equals是用在有多个hashcode相同的情况下查找需要的key。

2.3.2 为什么要重写equals()方法?

因为object中的equals()方法比较的是对象的引用地址是否相等,如何你需要判断对象里的内容是否相等,则需要重写equals()方法。

2.3.3 为什么改写了equals(),也需要改写hashcode()

如果你重载了equals,比如说是基于对象的内容实现的,而保留hashCode的实现(基于内存地址的hash值)不变,那么在添加进map中时需要比对hashcode,很可能某两个对象明明是“相等”,而hashCode却不一样。

2.3.4 为什么改写了hashcode(),也需要改写equals()

Hashmap的key可以是任何类型的对象,例如User这种对象,为了保证两个具有相同属性的user的hashcode相同,我们就需要改写hashcode方法,比方把hashcode值的计算与User对象的id关联起来,那么只要user对象拥有相同id,那么他们的hashcode也能保持一致了,这样就可以找到在hashmap数组中的位置了。如果这个位置上有多个元素,还需要用key的equals方法在对应位置的链表中找到需要的元素,所以只改写了hashcode方法是不够的,equals方法也是需要改写。

在改写equals方法的时候,需要满足以下三点:
(1) 自反性:就是说a.equals(a)必须为true。
(2) 对称性:就是说a.equals(b)=true的话,b.equals(a)也必须为true。
(3) 传递性:就是说a.equals(b)=true,并且b.equals©=true的话,a.equals©也必须为true。
通过改写key对象的equals和hashcode方法,我们可以将任意的业务对象作为map的key(前提是你确实有这样的需要)。

三、扩容机制

当hashmap中的元素个数超过数组大小*loadFactor时,就会进行数组扩容,loadFactor的默认值为0.75,也就是说,默认情况下,数组大小为16,那么当hashmap中元素个数超过16*0.75=12的时候,就把数组的大小扩展为2*16=32,即扩大为原来2倍,然后重新调用hash方法找到新的bucket位置。

3.1 resize()

jdk1.7的源码

void resize(int newCapacity) {
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    ......
    // 创建一个新的 Hash Table
    Entry[] newTable = new Entry[newCapacity];
    // 将 Old Hash Table 上的数据迁移到 New Hash Table 上
    transfer(newTable);
    table = newTable;
    threshold = (int)(newCapacity * loadFactor);
}
// 迁移数组
void transfer(Entry[] newTable) {
    Entry[] src = table;
    int newCapacity = newTable.length;
    //下面这段代码的意思是:
    // 从OldTable里摘一个元素出来,然后放到NewTable中
    for (int j = 0; j < src.length; j++) {
        Entry<K,V> e = src[j];
        if (e != null) {
            src[j] = null;
            do {
                Entry<K,V> next = e.next;
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            } while (e != null);
        }
    }
}

迁移过程:

单线程下的迁移:在扩容之后,重新计算hash定位到新数组中,相同hash值的元素照样连接成链表,只是链表相对位置进行了反转。

多线程下的迁移:

线程1在获取next结点之后被挂起,Thread 1 的 e 指向了 key(3),而 next 指向了 key(7)。线程2顺利完成rehash过程,链表反转。

do {
    Entry<K,V> next = e.next; // 假设线程一执行到这里就被调度挂起了
    int i = indexFor(e.hash, newCapacity);
    e.next = newTable[i];
    newTable[i] = e;
    e = next;
} while (e != null);

线程1继续执行,仍会把线程二的新表当成原始的hash表,将原来e指向的key(3)节点当成是线程二中的key(3),放在自己所建newTable[3]的头节点,线程1的next仍然指向key(7),此时key(3)的next已经是null。

 e.next = newTable[i];	// key(3)的 next 指向了线程1的新 Hash 表,因为新 Hash 表为空,所以e.next = null
 newTable[i] = e;	// 线程1的新 Hash 表第一个元素指向了线程2新 Hash 表的 key(3)。e 处理完毕
 e = next;			// 将 e 指向 next,所以新的 e 是 key(7)

线程1的e指向了上一次循环的next,也就是key(7),此时key(7)的next已经是key(3)。将key(7)插入到table[0]的头节点,并且将key(7)的next设置为key(3), e 和next继续往下移。此时仍然没有问题。

继续下一次循环,e.next = newTable[i] 导致 key(3).next 指向了 key(7),但此时的 key(7).next 已经指向了 key(3), 环形链表就这样出现了。

jdk1.8的源码


final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    //以前的容量大于0,也就是hashMap中已经有元素了,或者new对象的时候设置了初始容量
    if (oldCap > 0) {
        //如果以前的容量大于限制的最大容量1<<30,则设置临界值为int的最大值2^31-1
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    
    else if (oldThr > 0) 
        newCap = oldThr;
    
    else {               
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    //对临界值做判断,确保其不为0,因为在上面第二种情况(oldThr > 0),并没有计算newThr
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    
    @SuppressWarnings({"rawtypes","unchecked"})
    
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    //将刚创建的新表赋值给table
    table = newTab;
    if (oldTab != null) {
        //遍历将原来table中的数据放到扩容后的新表中来
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                //没有链表Node节点,直接放到新的table中下标为[e.hash & (newCap - 1)]位置即可
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                //如果是treeNode节点,则树上的节点放到newTab中
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                //如果e后面还有链表节点,则遍历e所在的链表,
                else { // 保证顺序
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        //记录下一个节点
                        next = e.next;
                        
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    if (loTail != null) {
                        loTail.next = null;
                        
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

总结:

  1. 如果table == null, 则为HashMap的初始化, 生成空table返回即可;

  2. 如果table不为空, 需要重新计算table的长度, newLength = oldLength << 1(注, 如果原oldLength已经到了上限, 则newLength = oldLength);

  3. 遍历oldTable,oldTable[i]为空,遍历下一个

    • 否:判断oldTable[i].next是否为空
      • 是:存放到newTable中newTab[e.hash & (newCap - 1)]
      • 否:判断是否红黑树
        • 是:走红黑树的重定位
        • 否:JAVA7时还需要重新计算hash位, 但是JAVA8做了优化, 通过(e.hash & oldCap)== 0来判断节点位置通过再次hash算法后,是否会发生改变
          • 是:移动到当前hash槽位 + oldCap的位置
          • 否:移动到新表中原下标的位置

注:newCap/oldCap为容量

四、面试题

4.1 扩容为什么是2倍?

主要与HashMap计算添加元素的位置时,使用的位运算有关,这是特别高效的运算;HashMap的初始容量是2的n次幂,扩容也是2倍的形式进行扩容,可以使得添加的元素均匀分布在HashMap中的数组上,减少hash碰撞,避免形成链表的结构,使得查询效率降低。

4.2 为什么String, Interger这样的wrapper类适合作为键?

如果两个不相等的对象返回不同的hashcode的话,那么碰撞的几率就会小些,这样就能提高HashMap的性能,也就适合做Hashmap的键。因为获取对象的时候要用到equals()和hashCode()方法,键对象正确的重写这两个方法是非常重要的。
因此,String,Interger这样的wrapper类作为HashMap的键是再适合不过了,而且String最为常用。因为String是不可变的,也是final的,而且已经重写了equals()和hashCode()方法了。其他的wrapper类也有这个特点。不可变性是必要的,因为为了要计算hashCode(),就要防止键值改变,如果键值在放入时和获取时返回不同的hashcode的话,那么就不能从HashMap中找到你想要的对象

4.3 线程不安全的原因

HashMap在并发场景下可能存在以下问题:

死循环:在jdk1.7中,resize过程中,从旧数组重新迁移至新数组的过程中,仍可能会发生hash冲突,形成链表,链表的相对位置发生了反转,那么在并发环境下,容易出现多线程同时resize的情况,那么就有可能在迁移过程中发生闭环,一旦发生闭环,进行get()操作的时候就会陷入死循环。在jdk1.8中,用 head 和 tail 来保证链表的顺序和之前一样,因此不会出现发生闭环的情况。

数据丢失

  1. 如果多个线程同时使用 put 方法添加元素,而且假设正好存在两个 put 的 key 发生了碰撞(根据 hash 值计算的 bucket 一样),那么根据 HashMap 的实现,这两个 key 会添加到数组的同一个位置,这样最终就会发生其中一个线程 put 的数据被覆盖

  2. 如果多个线程同时检测到元素个数超过数组大小 * loadFactor,这样就会发生多个线程同时对 Node 数组进行扩容,都在重新计算元素位置以及复制数据,但是最终只有一个线程扩容后的数组会赋给 table,也就是说其他线程的都会丢失,并且各自线程 put 的数据也丢失

4.4 你了解重新调整HashMap大小存在什么问题吗?

Jdk1.7 当多线程的情况下,可能产生条件竞争(race condition)。

当重新调整HashMap大小的时候,如果两个线程都发现HashMap需要重新调整大小了,它们会同时试着调整大小。在调 整大小的过程中,存储在链表中的元素的次序会反过来,因为移动到新的bucket位置的时候,HashMap并不会将元素放在链表的尾部,而是放在头部, 这是为了避免尾部遍历(tail traversing)。如果条件竞争发生了,那么就死循环了。

注:尾部遍历(避免尾部遍历是为了避免在新列表插入数据时,遍历队尾的位置。因为,直接插入的效率更高。)

死循环的发生

 
打赏
 本文转载自:网络 
所有权利归属于原作者,如文章来源标示错误或侵犯了您的权利请联系微信13520258486
更多>最近资讯中心
更多>最新资讯中心
0相关评论

推荐图文
推荐资讯中心
点击排行
最新信息
新手指南
采购商服务
供应商服务
交易安全
关注我们
手机网站:
新浪微博:
微信关注:

13520258486

周一至周五 9:00-18:00
(其他时间联系在线客服)

24小时在线客服