• HashMap源码阅读(jekyll迁移)


    layout: post
    title: HashMap源码阅读
    date: 2020-02-02
    author: xiepl1997
    tags: 源码阅读

    下面是JDK11中HashMap的源码分析,对代码的分析将主要以注释的方式来体现。

    1 概述

    1.1 HashMap的主要概念

    1. HashMap是基于Map接口实现的哈希表,实现了Map接口中的所有操作,而且HashMap允许键为空值,也允许值为空值,与之对应的是Hashtable,Hashtable不能将键和值设置为空。HashMap不能保证元素的顺序,特别是,它不能保证随着时间的推移保持顺序不变。

    2. HashMap为基本操作(get和put)提供了恒定的时间性能,假设散列函数在木桶(buckets)中适当地分散了元素。集合(Collection)的迭代需要的时间与HashMap实例的“容量”和它的大小成比例。因此,如果迭代的性能很重要,要求很高,那么不将初始容量设置得太高(或负载因素过低)是非常重要的。

    3. HashMap是线程不安全的集合,即当多线程访问时,同一时刻如果无法保证只有一个线程修改HashMap,则会毁坏HashMap,抛出ConcurrentModificationException。

    1.2 HashMap的基本实现

    HashMap底层使用哈希表(数组 + 单链表),当链表过长会将链表转成红黑树以实现O(logn)时间复杂度内查找。
    HashMap的定义为class HashMap<K, V> extends AbstractMap<K, V> implements Map<K, V>, Cloneable, Serializable

    1.3 HashMap内部类

    1.Node
    2.KeySet
    3.Values
    4.EntrySet
    5.HashIterator
    6.KeyIterator
    7.ValueIterator
    8.EntryIterator
    9.HashMapSpliterator
    10.KeySpliterator
    11.ValueSpliterator
    12.EntrySpliterator
    13.TreeNode 代表红黑树节点,HashMap中对红黑树的操作的方法都在此类中

    1.4 扩容原理

    HashMap采用的扩容策略是,每次加倍的方式。这样,原来位置的Entry在新扩展的数组中要么依然在原来的位置,要么在原来的位置+原来的容量的位置。

    1.5 hash计算

    HashMap通过hash()函数(也叫“扰动函数”)来计算hash值,方法为key == null ? 0 : (h = key.hashCode()) ^ h >>> 16;,计算出来的hash值存放在Node.hash中。

    hash的值计算相当于将高16位与底16位进行异或,结果是高16位不变,底16位变成异或的新结果。为什么这样做呢,原因是HashMap扩容之前的数组大小才为16,散列值是不能直接拿来用的。在进行长度取模运算时采用的只是取二进制中的最右端的几位,并没有用到高位二进制的信息,做带来的结果就是hash结果分布不太均匀。而将高16位和底16位异或后就可以让低位附带高位的信息,加大低位的随机性。

    在对散列值做完高低位的异或操作后,在对异或结果进行对长度的取模得到最终的结果。具体参考JDK源码中HashMap的hash方法原理是什么?-胖君的回答-知乎

    1.6 插入null原理

    在hash计算中,null的hash值为0,然后按照正常的putVal()插入。

    1.7 new HashMap()

    从源码中(下文构造函数)我们可以看到,new HashMap()开销非常少,仅仅确认装载因子。真正的创建table的操作尽可能的往后延迟,这使得HashMap有不少操作都需要检查table是否初始化。这种设计有一种好处,就是能够不必担心HashMap的开销,可以一次性大量的创建HashMap。

    2 HashMap的成员变量

    public class HashMap<K, V> extends AbstractMap<K, V> implements Map<K, V>, Cloneable, Serializable {
    	//用于序列化
    	private static final long serialVersionUID = 362498820763181265L;
    	//HashMap的默认容量是16
    	static final int DEFAULT_INITIAL_CAPACITY = 16;
    	//最大容量为1073741824(2的30次方,即1<<30)
    	static final int MAXIMUM_CAPACITY = 1073741824;
    	//默认装载因子为0.75f
    	static final float DEFAULT_LOAD_FACTOR = 0.75F;
    	/*
    	将链表转化为红黑树的阈值为8,即当链表长度 >= 8时,链表转化为红黑树,也就是树形化。
    	为什么要树形化呢?想一下我们为什么要用HashMap,是因为通过Hash算法在理想情况下时间复杂度O(1)就能找到元素,特别快,但仅限于理想情况下,如果遇到了hash碰撞,且碰撞比较频繁的话,那么当我们get一个元素的时候,定位到了这个数组,还需要在这个数组中遍历一次链表最终才能找到要get的元素,是不是已经失去了hashmap的初心了?(因为需要遍历链表,所以时间复杂度就高上去了)。
    	所以使用红黑树这种数据结构来解决链表过长的问题,可以理解为红黑树遍历比链表遍历快,时间复杂度低。
    	*/
    	static final int TREEIFY_THRESHOLD = 8;
    	//将红黑树转化成链表的阈值为6(<6时),这个是在resize()的过程中调用TreeNode.split()实现
    	static final int UNTREEIFY_THRESHOLD = 6;
    	/*
    	最小树形化阈值。要树化并不仅仅是要超过TREEIFY_THRESHOLD,同时容量要超过MIN_TREEIFY_CAPACITY,如果只是超过TREEIFY_THRESHOLD,则会进行扩容(调用resize())。为什么这个时候是扩容而不是树形化呢?
    	原因就在于,造成链表过长也可能是数组(桶)太短了也就是容量太小了。举个例子,如果数组长度为1,那么所有的元素都挤在了数组的第0个位置上,这个时候就算树形化只是治标不治本,因为引起链表过长的根本原因是数组过短。
    	所以在执行树形化之前(链表长度>=8),会检查数组长度,如果长度小于64,则对数组进行扩容,而不是树形化。
    	*/
    	static final int MIN_TREEIFY_CAPACITY = 64;
    	/*
    	哈希表的数组主体定义,初始化时,在构造函数中并不会初始化,所以在各种操作中总是要先检查table是否为null。
    	*/
    	transient HashMap.Node<K, V>[] table;
    	/*
    	作为一个entrySet缓存,使用entrySet首先检查其是否为null,不为null则使用这个缓存,否则生成一个entrySet并缓存至此。
    	*/
    	transient Set<Entry<K, V>> entrySet;
    	//HashMap中的Entry的数量
    	transient int size;
    	/*
    	记录修改内部结构化修改次数,用于实现fail-fast,ConcurrentModificationException就是通过检测这个抛出。
    	*/
    	transient int modCount;
    	//其值=capacity*loadFactor,当size超过threshold的时候便进行一次扩容
    	int threshold;
    	//装载因子
    	final float loadFactor;
    
    	……
    }
    

    3 HashMap的方法

    3.1 构造函数

    1. 该构造函数进行对容量和装载因子的合法性验证,然而没有对容量进行存储,只是用来确定扩容阈值threshold。
    public HashMap(int initialCapacity, float loadFactor) {
    	if (initialCapacity < 0) {
    		throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
    	} else {
    		if (initialCapacity > 1073741824) {
    			initialCapacity = 1073741824;
    		}
    
    		if (loadFactor > 0.0F && !Float.isNaN(loadFactor)) {
    			this.loadFactor = loadFactor;
    			this.threshold = tableSizeFor(initialCapacity);
    		} else {
    			throw new IllegalArgumentException("Illegal load factor: " + loadFactor);
    		}
    	}
    }
    
    1. 只传入初始容量
    public HashMap(int initialCapacity) {
    	this(initialCapacity, 0.75F);
    }
    
    1. 无参构造函数仅仅确认装载因子
    public HashMap() {
    	this.loadFactor = 0.75F;
    }
    
    1. 通过Map构造HashMap时,使用默认装载因子,并调用putMapEntries将Map装入HashMap
    	public HashMap(Map<? extends K, ? extends V> m) {
    		this.loadFactor = 0.75F;
    		this.putMapEntries(m, false);
    	}
    
    	final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
    		//获取该map实际长度
    	    int s = m.size();
    	    if (s > 0) {
    	    	//判断table是否初始化,如果没有初始化
    	        if (this.table == null) {
    	        	/**求出需要的容量,因为实际使用的长度=容量*0.75得来的,+1是因为小数相除,基本都不会是整数,容量大小
    				不能为小数的,后面转化为int,多余的小数就要被丢掉,所以+1,例如,map实际长度为29.3,则所需要的容量为30.
    	        	*/
    	            float ft = (float)s / this.loadFactor + 1.0F;
    	            //判断该容量大小是否超出上限
    	            int t = ft < 1.07374182E9F ? (int)ft : 1073741824;
    	            //对临界值进行初始化,tableSizeFor(t)这个方法会返回大于t值的,且离其最近的2次幂,例如t为29,则返回的值是32
    	            if (t > this.threshold) {
    	                this.threshold = tableSizeFor(t);
    	            }
    	        } else if (s > this.threshold) {  //如果table已经初始化,则进行扩容操作,resize就是扩容
    	            this.resize();
    	        }
    
    	        Iterator var8 = m.entrySet().iterator();
    
    	        //遍历,把map中的数据转移到hashmap中
    	        while(var8.hasNext()) {
    	            Entry<? extends K, ? extends V> e = (Entry)var8.next();
    	            K key = e.getKey();
    	            V value = e.getValue();
    	            this.putVal(hash(key), key, value, false, evict);
    	        }
    	    }
    
    	}
    

    该构造函数,传入一个Map,然后把该Map转为hashMap,resize方法在下面添加元素的时候会详细讲解,在上面entrySet方法会返回一个Set<Map.Entry<K,V>>,泛型为Map的内部类Entry,它是一个存放key-value的实例,也就是Map中的每一个key-value就是一个Entry实例,为什么使用这个方式进行遍历,因为效率高,putVal方法把取出来的每个key-value存入到hashMap中。

    3.2 hash(Object key)

    hash函数负责产生hashcode,计算方法为若为空则返回0,否则返回对key的高16位和底16位的异或的结果。

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

    3.3 comparableClassFor(Object x)

    这个方法是判断传入的Object对象x是否实现了Comparable接口,如果传入的是String对象,自然实现了Comparable接口,直接返回就行。但是对于其他的类,比方说我们自己写了一个类对象,然后存在HashMap中,但是就HashMap来说它并不知道我们有没有实现Comparable接口,甚至都不知道我们Comparable接口中有没有用泛型,泛型具体用的是哪个类。

    static Class<?> comparableClassFor(Object x) {
    	if (x instanceof Comparable) {
    		Class c;
    		if ((c = x.getClass()) == String.class) {
    			return c;
    		}
    
    		ype[] ts;
    		if ((ts = c.getGenericInterfaces()) != null) {
    			Type[] var5 = ts;
    			int var6 = ts.length;
    
    			for(int var7 = 0; var7 < var6; ++var7) {
    				Type t = var5[var7];
    				Type[] as;
    				ParameterizedType p;
    				if (t instanceof ParameterizedType && (p = (ParameterizedType)t).getRawType() == Comparable.class && (as = p.getActualTypeArguments()) != null && as.length == 1 && as[0] == c) {
    					return c;
    				}
    			}
    		}
    	}
    	return null;
    }
    

    3.4 compareComparables(Class<?> kc, Object k, Object x)

    如果x为空,返回0;如果x的类型为kc,则返回compareTo(x)。

    static int compareComparables(Class<?> kc, Object k, Object x) {
    	return x != null && x.getClass() == kc ? ((Comparable)k).compareTo(x) : 0;
    }
    

    3.5 tableSizeFor(int cap)

    该函数用于计算大于等于cap的的最小的2的整数幂,用于做table的长度。numberOfLeadingZeros()方法的作用是返回无符号整形i的最高非零位前面的0的个数,包括符号位在内。

    static final int tableSizeFor(int cap) {
    	int n = -1 >>> Integer.numberOfLeadingZeros(cap - 1);
    	return n < 0 ? 1 : (n >= 1073741824 ? 1073741824 : n + 1);
    }
    

    3.6 put(K key, V value)

    	public V put(K key, V value) {
    		//四个参数,第一个hash值,第四个参数表示如果该key存在值,如果为null的话,则插入新的value,最后一个参数,在hashMap中没有用,可以不用管。
            return this.putVal(hash(key), key, value, false, true);
        }
    
        final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
        	//tab哈希数组,p为该哈希桶的首节点,n为hashMap的长度,i为计算出的数组下标
            HashMap.Node[] tab;
            int n;
            //获取长度并扩容,使用的是懒加载,table一开始是没有加载的,等puthou才开始加载
            if ((tab = this.table) == null || (n = tab.length) == 0) {
                n = (tab = this.resize()).length;
            }
    
            Object p;
            int i;
            //如果计算出的该哈希桶的位置没有值,则把新插入的key-value放到此处,此处就算没有插入成功,也就是发生哈希冲突时也会把哈希桶的首节点赋予p
            if ((p = tab[i = n - 1 & hash]) == null) {
                tab[i] = this.newNode(hash, key, value, (HashMap.Node)null);
            } else {  //发生哈希冲突的几种情况
            	//e临时节点的作用,k存放当前节点的key值
                Object e;
                Object k;
                //第一种,插入的key-value的hash值,key都与当前节点相等,e=p,则表示为首节点
                if (((HashMap.Node)p).hash == hash && ((k = ((HashMap.Node)p).key) == key || key != null && key.equals(k))) {
                    e = p;
                }
                //第二种,hash值不等于首节点,判断该p是否属于红黑树的节点
                else if (p instanceof HashMap.TreeNode) {
                	/*
                	为红黑树的节点,则在红黑树中进行添加,如果该节点已经存在,则返回该节点(不为null),
                	该值很重要,用来判断put操作是否成功,如果添加成功返回null
                	*/
                    e = ((HashMap.TreeNode)p).putTreeVal(this, tab, hash, key, value);
                }
                //第三种,hash值不等于首节点,不为红黑树节点,则为链表的节点
                else {
                	//遍历该链表
                    int binCount = 0;
                    while(true) {
                    	//如果找到尾部,则表明添加的key-value没有重复,在尾部进行添加。
                        if ((e = ((HashMap.Node)p).next) == null) {
                            ((HashMap.Node)p).next = this.newNode(hash, key, value, (HashMap.Node)null);
                            //判断是否要转化为红黑树结构
                            if (binCount >= 7) {
                                this.treeifyBin(tab, hash);
                            }
                            break;
                        }
    
                        //如果链表有重复的key,e为当前重复的节点,结束循环。
                        if (((HashMap.Node)e).hash == hash && ((k = ((HashMap.Node)e).key) == key || key != null && key.equals(k))) {
                            break;
                        }
    
                        p = e;
                        ++binCount;
                    }
                }
                //e不为null,则说明有重复的key,则用待插入值进行覆盖,返回旧值。
                if (e != null) {
                    V oldValue = ((HashMap.Node)e).value;
                    if (!onlyIfAbsent || oldValue == null) {
                        ((HashMap.Node)e).value = value;
                    }
    
                    this.afterNodeAccess((HashMap.Node)e);
                    return oldValue;
                }
            }
    
            /*
            到了这步,说明待插入的key-value是没有key的重复,因为插入成功的e节点的值为null。
            修改次数+1
            */
            ++this.modCount;
            //实际长度+1,并判断是否大于临界值,大于则扩容
            if (++this.size > this.threshold) {
                this.resize();
            }
    
            this.afterNodeInsertion(evict);
            //添加成功
            return null;
        }
    

    3.7 resize()

    扩容方法resize()

        final HashMap.Node<K, V>[] resize() {
        	//把没有插入之前的哈希数组叫做oldTab
            HashMap.Node<K, V>[] oldTab = this.table;
            //oldTab的长度
            int oldCap = oldTab == null ? 0 : oldTab.length;
            //oldTab的临界值
            int oldThr = this.threshold;
            //初始化new的长度和临界值
            int newThr = 0;
            int newCap;
            //oldCap>0也就说明不是首次加载,因为hashMap用的是懒加载
            if (oldCap > 0) {
            	//如果大于最大值
                if (oldCap >= 1073741824) {
                	//将临界值设置为整数的最大值
                    this.threshold = 2147483647;
                    return oldTab;
                }
                //位置*。其他情况,扩容两倍,并且扩容后的长度要小于最大值,old的长度也要大于16
                if ((newCap = oldCap << 1) < 1073741824 && oldCap >= 16) {
                	//临界值也要扩容为old的2倍
                    newThr = oldThr << 1;
                }
            }
            /*
            如果oldCap<0,但是已经初始化了,像把元素删除完之后的情况,那么它的临界值肯定还存在,
            如果是首次初始化,它的临界值则为0.
            */
            else if (oldThr > 0) {
                newCap = oldThr;
            }
            //首次初始化,给默认值
            else {
                newCap = 16;
                newThr = 12;  //临界值等于容量*0.75
            }
            //位置*的补充,也就是初始化时容量小于默认值16的,此时newThr没有赋值
            if (newThr == 0) {
            	//new的临界值
                float ft = (float)newCap * this.loadFactor;
                //判断new容量是否大于最大值,临界值是否大于最大值
                newThr = newCap < 1073741824 && ft < 1.07374182E9F ? (int)ft : 2147483647;
            }
    
            //把上面各种情况分析出的临界值,在此处进行真正的改变,也就是容量和临界值都改变了
            this.threshold = newThr;
            //初始化
            HashMap.Node<K, V>[] newTab = new HashMap.Node[newCap];
            //赋予当前的table
            this.table = newTab;
            //此处自然是把old中的元素,遍历到new中
            if (oldTab != null) {
                for(int j = 0; j < oldCap; ++j) {
                	//临时变量
                    HashMap.Node e;
                    //当前哈希桶的位置值不为null,也就是数组下标处有值,因为有值表示可能会发生冲突
                    if ((e = oldTab[j]) != null) {
                    	//把已经赋值之后的变量置位null,当然是为了好回收,释放内存
                        oldTab[j] = null;
                        //如果下标处的节点没有下一个元素
                        if (e.next == null) {
                        	//把该变量的值存入newTab中,e.hash & new Cap-1并不等于j
                            newTab[e.hash & newCap - 1] = e;
                        }
                        //如果该节点为红黑树结构,也就是存在hash冲突,该hash桶中有多个元素
                        else if (e instanceof HashMap.TreeNode) {
                        	//把此树转移到newTab中
                            ((HashMap.TreeNode)e).split(this, newTab, j, oldCap);
                        }
                        /*
                        此处表示为链表结构,同样把链表转移到newTab中,就是把链表遍历后,把值转过去,再置位null
                        */
                        else {
                            HashMap.Node<K, V> loHead = null;
                            HashMap.Node<K, V> loTail = null;
                            HashMap.Node<K, V> hiHead = null;
                            HashMap.Node hiTail = null;
    
                            HashMap.Node 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;
                                }
    
                                e = next;
                            } while(next != null);
    
                            if (loTail != null) {
                                loTail.next = null;
                                newTab[j] = loHead;
                            }
    
                            if (hiTail != null) {
                                hiTail.next = null;
                                newTab[j + oldCap] = hiHead;
                            }
                        }
                    }
                }
            }
            //返回扩容后的hashmap
            return newTab;
        }
    

    3.8 remove(Object key)

    删除元素

        public V remove(Object key) {
        	//临时变量
            HashMap.Node e;
            /*
            调用removeNode,第三个value表示,把key的节点直接都删除了,不需要用到值,
            如果设为值,则还需要去进行查找操作。
            */
            return (e = this.removeNode(hash(key), key, (Object)null, false, true)) == null ? null : e.value;
        }
    
        /*
        第一参数为哈希值,第二个为key,第三个为value,第四个为true的话,则表示删除它
        key对应的value,不删除key,第四个如果为false,则表示删除后,不移动节点。
        */
        final HashMap.Node<K, V> removeNode(int hash, Object key, Object value, boolean matchValue, boolean movable) {
        	//tab哈希数组,p数组下标节点,n长度,index当前数组下标
            HashMap.Node[] tab;
            HashMap.Node p;
            int n;
            int index;
            //哈希数组不为null,且长度大于0,然后获得要删除key的节点的数组下标位置
            if ((tab = this.table) != null && (n = tab.length) > 0 && (p = tab[index = n - 1 & hash]) != null) {
            	//node存储要删除的节点,e临时变量,k当前节点的key,v当前节点的value
                HashMap.Node<K, V> node = null;
                Object k;
                //如果数组下标的节点正好是要删除的节点,把值赋给临时变量
                if (p.hash == hash && ((k = p.key) == key || key != null && key.equals(k))) {
                    node = p;
                }
                //也就是要删除的节点,在链表或者红黑树上,先判断是否为红黑树的节点
                else {
                    HashMap.Node e;
                    if ((e = p.next) != null) {
                        if (p instanceof HashMap.TreeNode) {
                        	//遍历红黑树,找到该节点并返回
                            node = ((HashMap.TreeNode)p).getTreeNode(hash, key);
                        }
                        //如果是链表节点,遍历找到该节点
                        else {
                            label88: {
                                while(e.hash != hash || (k = e.key) != key && (key == null || !key.equals(k))) {
                                	//p为要删除节点的上一个节点
                                    p = e;
                                    if ((e = e.next) == null) {
                                        break label88;
                                    }
                                }
    
                                //node为要删除的节点
                                node = e;
                            }
                        }
                    }
                }
    
                Object v;
                /*
                找到要删除的节点后,判断!matchValue,我们正常的remove删除,!matchValue都为true
                */
                if (node != null && (!matchValue || (v = ((HashMap.Node)node).value) == value || value != null && value.equals(v))) {
                    //如果删除的节点是红黑树节点,则从红黑树中删除
                    if (node instanceof HashMap.TreeNode) {
                        ((HashMap.TreeNode)node).removeTreeNode(this, tab, movable);
                    }
                    //如果是链表节点,且删除的节点为数组下标节点,也就是头节点,直接让下一个作为头。
                    else if (node == p) {
                        tab[index] = ((HashMap.Node)node).next;
                    }
                    //为链表结构,删除的节点在链表中,要把删除的下一个节点设为上一个节点的下一个节点。
                    else {
                        p.next = ((HashMap.Node)node).next;
                    }
    
                    //修改计数器
                    ++this.modCount;
                    //长度减1
                    --this.size;
                    this.afterNodeRemoval((HashMap.Node)node);
                    //返回删除的节点
                    return (HashMap.Node)node;
                }
            }
    
            return null;
        }
    

    删除还有clear方法,把所有的数组下标元素都置位null。

    3.9 get()

    下面在看较为简单的获取元素。

        public V get(Object key) {
            HashMap.Node e;
            //也是调用getNode方法来完成
            return (e = this.getNode(hash(key), key)) == null ? null : e.value;
        }
    
        final HashMap.Node<K, V> getNode(int hash, Object key) {
        	//tab哈希数组,first头节点,n长度,k为key
            HashMap.Node[] tab;
            HashMap.Node first;
            int n;
            //如果哈希数组不为null,且长度大于0,获取key值所在的链表头赋值给first
            if ((tab = this.table) != null && (n = tab.length) > 0 && (first = tab[n - 1 & hash]) != null) {
                Object k;
                //如果是头节点,则直接返回头节点。
                if (first.hash == hash && ((k = first.key) == key || key != null && key.equals(k))) {
                    return first;
                }
    
                HashMap.Node e;
                //结果不是头节点
                if ((e = first.next) != null) {
                	//判断是否是红黑树结构
                    if (first instanceof HashMap.TreeNode) {
                    	//去红黑树中找,然后返回
                        return ((HashMap.TreeNode)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;
        }
    

    hashMap源码暂时分析到这里,能力有限,如果内容出现错误,欢迎指出。

    计算机研狗一只
  • 相关阅读:
    一段自己写的丑陋的表单验证代码
    简单的星级评价
    有个项目
    好久没写了,重装了系统重新配置的Live Writer,看看效果:
    XmlHttpRequest调用Webservice的一点心得
    局域网共享怎么设置?我想把其中一个电脑的F盘共享?
    TCP/IP协议详解
    input file实现多张图片上传
    .NET C#中如何备份SQL数据库
    CSS中cursor鼠标形状属性列表
  • 原文地址:https://www.cnblogs.com/xiepl1997/p/13493112.html
Copyright © 2020-2023  润新知