• 【Java深入研究】9、HashMap源码解析(jdk 1.8)


    一、HashMap概述

      HashMap是常用的Java集合之一,是基于哈希表的Map接口的实现。与HashTable主要区别为不支持同步和允许null作为key和value。由于HashMap不是线程安全的,如果想要线程安全,可以使用ConcurrentHashMap代替。

    二、HashMap数据结构

      HashMap的底层是哈希数组,数组元素为Entry。HashMap通过key的hashCode来计算hash值,当hashCode相同时,通过“拉链法”解决冲突

      相比于之前的版本,jdk1.8在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)时,将链表转化为红黑树,以减少搜索时间。原本Map.Entry接口的实现类Entry改名为了Node。转化为红黑树时改用另一种实现TreeNode。 

    Node类

    static class Node<K,V> implements Map.Entry<K,V> {
            final int hash; // 哈希值
            final K key;
            V value;
            Node<K,V> next; // 指向下一个节点
    
            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;
            }
    
            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;
            }
        }

    TreeNode类

    static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
            TreeNode<K,V> parent;  // red-black tree links
            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);
            }
        }

    HashMap就是这样一个Entry(包括Node和TreeNode)数组,Node对象中包含键、值和hash值,next指向下一个Entry,用来处理哈希冲突。TreeNode对象包含指向父节点、子节点和前一个节点(移除对象时使用)的指针,以及表示红黑节点的boolean标识。

    三、HashMap源码分析

    0、定位哈希桶数组索引位置

    不管增加、删除、查找键值对,定位到哈希桶数组的位置都是很关键的第一步。前面说过HashMap的数据结构是“数组+链表+红黑树”的结合,所以我们当然希望这个HashMap里面的元素位置尽量分布均匀些,尽量使得每个位置上的元素数量只有一个,那么当我们用hash算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的,不用遍历链表/红黑树,大大优化了查询的效率。HashMap定位数组索引位置,直接决定了hash方法的离散性能。下面是定位哈希桶数组的源码:

    // 代码1
    static final int hash(Object key) { // 计算key的hash值
    int h;
    // 1.先拿到key的hashCode值; 2.将hashCode的高16位参与运算
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
    // 代码2
    int n = tab.length;
    // 将(tab.length - 1) 与 hash值进行&运算
    int index = (n - 1) & hash;
    整个过程本质上就是三步:
    
    拿到key的hashCode值
    将hashCode的高位参与运算,重新计算hash值
    将计算出来的hash值与(table.length - 1)进行&运算 

    方法解读:

    对于任意给定的对象,只要它的hashCode()返回值相同,那么计算得到的hash值总是相同的。我们首先想到的就是把hash值对table长度取模运算,这样一来,元素的分布相对来说是比较均匀的。

    但是模运算消耗还是比较大的,我们知道计算机比较快的运算为位运算,因此JDK团队对取模运算进行了优化,使用上面代码2的位与运算来代替模运算。这个方法非常巧妙,它通过 “(table.length -1) & h” 来得到该对象的索引位置,这个优化是基于以下公式:x mod 2^n = x & (2^n - 1)。我们知道HashMap底层数组的长度总是2的n次方,并且取模运算为“h mod table.length”,对应上面的公式,可以得到该运算等同于“h & (table.length - 1)”。这是HashMap在速度上的优化,因为&比%具有更高的效率。

    在JDK1.8的实现中,还优化了高位运算的算法,将hashCode的高16位与hashCode进行异或运算,主要是为了在table的length较小的时候,让高位也参与运算,并且不会有太大的开销。

    1. 主要属性

        transient Node<K,V>[] table; // 哈希数组
    
        transient Set<Map.Entry<K,V>> entrySet; // entry缓存Set
    
        transient int size; // 元素个数
    
        transient int modCount; // 修改次数
    
        int threshold; // 阈值,等于加载因子*容量,当实际大小超过阈值则进行扩容
    
        final float loadFactor; // 加载因子,默认值为0.75

    2. 构造方法

      以下是HashMap的几个构造方法。

     /**
         * 根据初始化容量和加载因子构建一个空的HashMap.
         */
        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);
        }
    
        /**
         * 使用初始化容量和默认加载因子(0.75).
         */
        public HashMap(int initialCapacity) {
            this(initialCapacity, DEFAULT_LOAD_FACTOR);
        }
    
        /**
         * 使用默认初始化大小(16)和默认加载因子(0.75).
         */
        public HashMap() {
            this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
        }
    
        /**
         * 用已有的Map构造一个新的HashMap.
         */
        public HashMap(Map<? extends K, ? extends V> m) {
            this.loadFactor = DEFAULT_LOAD_FACTOR;
            putMapEntries(m, false);
        }

    3. 数据存取

    putAll方法

    public void putAll(Map<? extends K, ? extends V> m) {
            putMapEntries(m, true);
        }
    
        /**
         * Implements Map.putAll and Map constructor
         *
         * @param m the map
         * @param evict false when initially constructing this map, else
         * true (relayed to method afterNodeInsertion).
         */
        final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
            int s = m.size();
            if (s > 0) {
                if (table == null) { // pre-size
                    float ft = ((float)s / loadFactor) + 1.0F;
                    int t = ((ft < (float)MAXIMUM_CAPACITY) ?
                             (int)ft : MAXIMUM_CAPACITY);
                    if (t > threshold)
                        threshold = tableSizeFor(t);
                }
                else if (s > threshold)
                    resize();
                for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
                    K key = e.getKey();
                    V value = e.getValue();
                    putVal(hash(key), key, value, false, evict); // put核心方法
                }
            }
        }

    put方法

        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) // table为空或length为0
                n = (tab = resize()).length; // 初始化
            if ((p = tab[i = (n - 1) & hash]) == null) // 如果hash所在位置为null,直接put
                tab[i] = newNode(hash, key, value, null);
            else { // tab[i]有元素,遍历节点后添加
                Node<K,V> e; K k;
                // 如果hash、key都相等,直接覆盖
                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方法,当tab.length大于64将链表改为红黑树
                            // 如果tab.length < 64或tab为null,则调用resize方法重构链表.
                            if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                                treeifyBin(tab, hash);
                            break;
                        }
                        // hash、key都相等,此时节点即要更新节点
                        if (e.hash == hash &&
                            ((k = e.key) == key || (key != null && key.equals(k))))
                            break;
                        p = e;
                    }
                }
                // 当前节点e = p.next不为null,表示链表中原本存在相同的key,则返回oldValue
                if (e != null) { // existing mapping for key
                    V oldValue = e.value;
                    // onlyIfAbsent值为false,参数主要决定存在相同key时是否执行替换
                    if (!onlyIfAbsent || oldValue == null)
                        e.value = value;
                    afterNodeAccess(e);
                    return oldValue;
                }
            }
            ++modCount;
            if (++size > threshold) // 检查是否超过阈值
                resize();
            afterNodeInsertion(evict);
            return null; // 原HashMap中不存在相同的key,插入键值对后返回null
        }
    treeifyBin方法源码如下:
    复制代码
     1     final void treeifyBin(Node<K,V>[] tab, int hash) {
     2         int n, index; Node<K,V> e;
     3         //当tab为null或tab.length<64需要进行扩容
     4         if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
     5             resize();
     6         else if ((e = tab[index = (n - 1) & hash]) != null) {
     7             TreeNode<K,V> hd = null, tl = null;
     8             do {
     9                 TreeNode<K,V> p = replacementTreeNode(e, null);
    10                 if (tl == null)
    11                     hd = p;
    12                 else {
    13                     p.prev = tl;
    14                     tl.next = p;
    15                 }
    16                 tl = p;
    17             } while ((e = e.next) != null);
    18             if ((tab[index] = hd) != null)
    19                 //存储在红黑树
    20                 hd.treeify(tab);
    21         }
    22     }
    复制代码

    现在我们来看一下为什么需要扩容:

    复制代码
     1     /**
     2      * The bin count threshold for using a tree rather than list for a
     3      * bin.  Bins are converted to trees when adding an element to a
     4      * bin with at least this many nodes. The value must be greater
     5      * than 2 and should be at least 8 to mesh with assumptions in
     6      * tree removal about conversion back to plain bins upon
     7      * shrinkage.
     8      */
     9     static final int TREEIFY_THRESHOLD = 8;
    10 
    11     /**
    12      * The smallest table capacity for which bins may be treeified.
    13      * (Otherwise the table is resized if too many nodes in a bin.)
    14      * Should be at least 4 * TREEIFY_THRESHOLD to avoid conflicts
    15      * between resizing and treeification thresholds.
    16      */
    17     static final int MIN_TREEIFY_CAPACITY = 64;
    复制代码
    MIN_TREEIFY_CAPACITY=64 > 4 * TREEIFY_THRESHOLD=4*8=32

    如上图:转化红黑树的table容量最小容量64(JDK8定义的是64),至少是4*TREEIFY_THRESHOLD(单链表节点个数阀值,用以判断是否需要树形化)=32。用以避免 在扩容和树形结构化的阀值 可能产生的冲突。所以如果小于64必须要扩容。

     4、get查找

    可见外部查找和JDK7差别不大,只是原本是entry[]查询,JDK8编程Node[]查询.都是 hash(key)找到table中元素位置,再遍历找到链表(或者是红黑树)元素的key。根据元素类型判断到底是查询哪个类型

    复制代码
     1     public V get(Object key) {
     2         Node<K,V> e;
     3         return (e = getNode(hash(key), key)) == null ? null : e.value;
     4     }
     5 
     6     /**
     7      * Implements Map.get and related methods
     8      *
     9      * @param hash hash for key
    10      * @param key the key
    11      * @return the node, or null if none
    12      */
    13     final Node<K,V> getNode(int hash, Object key) {
    14         Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    15         if ((tab = table) != null && (n = tab.length) > 0 &&
    16             (first = tab[(n - 1) & hash]) != null) {
    17             if (first.hash == hash && // always check first node
    18                 ((k = first.key) == key || (key != null && key.equals(k))))
    19                 return first;
    20             if ((e = first.next) != null) {
    21                 if (first instanceof TreeNode)
    22                     return ((TreeNode<K,V>)first).getTreeNode(hash, key);
    23                 do {
    24                     if (e.hash == hash &&
    25                         ((k = e.key) == key || (key != null && key.equals(k))))
    26                         return e;
    27                 } while ((e = e.next) != null);
    28             }
    29         }
    30         return null;
    31     }    
    复制代码

     5、resize扩容

    第二部put的时候,有可能需要resize。 因为newCap是oldCap的两倍所以原节点的索引值要么和原来一样,要么就是原(索引+oldCap)和JDK 1.7中实现不同这里不存在rehash

    复制代码
     1 /**
     2      * Initializes or doubles table size.  If null, allocates in
     3      * accord with initial capacity target held in field threshold.
     4      * Otherwise, because we are using power-of-two expansion, the
     5      * elements from each bin must either stay at same index, or move
     6      * with a power of two offset in the new table.
     7      *
     8      * @return the table
     9      */
    10     final Node<K,V>[] resize() {
    11         Node<K,V>[] oldTab = table;
    12         int oldCap = (oldTab == null) ? 0 : oldTab.length;
    13         int oldThr = threshold;
    14         int newCap, newThr = 0;
    15         if (oldCap > 0) {
    16             if (oldCap >= MAXIMUM_CAPACITY) {
    17                 threshold = Integer.MAX_VALUE;
    18                 return oldTab;
    19             }
    20             else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
    21                      oldCap >= DEFAULT_INITIAL_CAPACITY)
    22                 newThr = oldThr << 1; // double threshold
    23         }
    24         else if (oldThr > 0) // initial capacity was placed in threshold
    25             newCap = oldThr;
    26         else {               // zero initial threshold signifies using defaults
    27             newCap = DEFAULT_INITIAL_CAPACITY;
    28             newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    29         }
    30         if (newThr == 0) {
    31             float ft = (float)newCap * loadFactor;
    32             newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
    33                       (int)ft : Integer.MAX_VALUE);
    34         }
    35         threshold = newThr;
    36         @SuppressWarnings({"rawtypes","unchecked"})
    37             Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    38         table = newTab;
    39         if (oldTab != null) {
    40             for (int j = 0; j < oldCap; ++j) {
    41                 Node<K,V> e;
    42                 if ((e = oldTab[j]) != null) {
    43                     oldTab[j] = null;
    44                     if (e.next == null)
    45                         newTab[e.hash & (newCap - 1)] = e;
    46                     else if (e instanceof TreeNode)
    47                         ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
    48                     else { // preserve order
    49                         Node<K,V> loHead = null, loTail = null;
    50                         Node<K,V> hiHead = null, hiTail = null;
    51                         Node<K,V> next;
    52                         do {
    53                             next = e.next;
    54                             if ((e.hash & oldCap) == 0) {
    55                                 if (loTail == null)
    56                                     loHead = e;
    57                                 else
    58                                     loTail.next = e;
    59                                 loTail = e;
    60                             }
    61                             else {
    62                                 if (hiTail == null)
    63                                     hiHead = e;
    64                                 else
    65                                     hiTail.next = e;
    66                                 hiTail = e;
    67                             }
    68                         } while ((e = next) != null);
    69                         if (loTail != null) {
    70                             loTail.next = null;
    71                             newTab[j] = loHead;
    72                         }
    73                         if (hiTail != null) {
    74                             hiTail.next = null;
    75                             //把节点移动新的位置j+oldCap,这种情况不适用与链表的节点数大于8的情况
    76                             //链表节点大于8的情况会转换为红黑树存储
    77                             newTab[j + oldCap] = hiHead;
    78                         }
    79                     }
    80                 }
    81             }
    82         }
    83         return newTab;
    84     }
    复制代码

    6.HashMap节点红黑树存储

    好了终于到treeify了,大部分内容都在注解中

    复制代码
     1 final void treeify(Node<K,V>[] tab) {
     2             TreeNode<K,V> root = null;
     3             for (TreeNode<K,V> x = this, next; x != null; x = next) {
     4                 next = (TreeNode<K,V>)x.next;
     5                 x.left = x.right = null;
     6                 if (root == null) {
     7                     x.parent = null;
     8                     x.red = false;
     9                     root = x;
    10                 }
    11                 else {
    12                     K k = x.key;
    13                     int h = x.hash;
    14                     Class<?> kc = null;
    15                     //遍历root,把节点x插入到红黑树中,执行先插入,然后进行红黑树修正
    16                     for (TreeNode<K,V> p = root;;) {
    17                         int dir, ph;
    18                         K pk = p.key;
    19                         if ((ph = p.hash) > h)
    20                             dir = -1;
    21                         else if (ph < h)
    22                             dir = 1;
    23                         else if ((kc == null &&
    24                                   (kc = comparableClassFor(k)) == null) ||
    25                                  (dir = compareComparables(kc, k, pk)) == 0)
    26                             dir = tieBreakOrder(k, pk);//比较k和pk的值,用于判断是遍历左子树还是右子树
    27                         TreeNode<K,V> xp = p;
    28                         if ((p = (dir <= 0) ? p.left : p.right) == null) {
    29                             x.parent = xp;
    30                             if (dir <= 0)
    31                                 xp.left = x;
    32                             else
    33                                 xp.right = x;
    34                             //修正红黑树
    35                             root = balanceInsertion(root, x);
    36                             //退出循环
    37                             break;
    38                         }
    39                     }
    40                 }
    41             }
    42             moveRootToFront(tab, root);
    43         }
    复制代码

    上面主要做的是红黑树的insert,我们知道红黑树insert后是需要修复的,为了保持红黑树的平衡,我们来看下红黑树平衡的几条性质:
    1.节点是红色或黑色。
    2.根是黑色。
    3.所有叶子都是黑色(叶子是NIL节点)。
    4.每个红色节点必须有两个黑色的子节点。(从每个叶子到根的所有路径上不能有两个连续的红色节点。)
    5.从任一节点到其每个叶子的所有简单路径都包含相同数目的黑色节点。

    当insert一个节点之后为了达到平衡,我们可能需要对节点进行旋转和颜色翻转(上面的balanceInsertion方法)。

    复制代码
     1 static <K,V> TreeNode<K,V> balanceInsertion(TreeNode<K,V> root,
     2                                                     TreeNode<K,V> x) {
     3             //插入的节点必须是红色的,除非是根节点                                            
     4             x.red = true;
     5             //遍历到x节点为黑色,整个过程是一个上滤的过程
     6             //xp=x.parent;xpp=xp.parent;xppl=xpp.left;xppr=xpp.right;
     7             for (TreeNode<K,V> xp, xpp, xppl, xppr;;) {
     8                 if ((xp = x.parent) == null) {
     9                     x.red = false;
    10                     return x;
    11                 }
    12                 //如果xp的黑色就直接完成,最简单的情况
    13                 else if (!xp.red || (xpp = xp.parent) == null)
    14                     return root;
    15                 //如果x的父节点是x父节点的左节点
    16                 if (xp == (xppl = xpp.left)) {
    17                     //x的父亲节点的兄弟是红色的(需要颜色翻转)
    18                     if ((xppr = xpp.right) != null && xppr.red) {
    19                         //x父亲节点的兄弟节点置成黑色
    20                         xppr.red = false;
    21                         //父几点和其兄弟节点一样是黑色
    22                         xp.red = false;
    23                         //祖父节点置成红色
    24                         xpp.red = true;
    25                         //然后上滤(就是不断的重复上面的操作)
    26                         x = xpp;
    27                     }
    28                     else {
    29                         //如果x是xp的右节点整个要进行两次旋转,先左旋转再右旋转
    30                         if (x == xp.right) {
    31                             root = rotateLeft(root, x = xp);
    32                             xpp = (xp = x.parent) == null ? null : xp.parent;
    33                         }
    34                         if (xp != null) {
    35                             xp.red = false;
    36                             if (xpp != null) {
    37                                 xpp.red = true;
    38                                 root = rotateRight(root, xpp);
    39                             }
    40                         }
    41                     }
    42                 }
    43                 //以左节点镜像对称就不做具体分析了 
    44                 else {
    45                     if (xppl != null && xppl.red) {
    46                         xppl.red = false;
    47                         xp.red = false;
    48                         xpp.red = true;
    49                         x = xpp;
    50                     }
    51                     else {
    52                         if (x == xp.left) {
    53                             root = rotateRight(root, x = xp);
    54                             xpp = (xp = x.parent) == null ? null : xp.parent;
    55                         }
    56                         if (xp != null) {
    57                             xp.red = false;
    58                             if (xpp != null) {
    59                                 xpp.red = true;
    60                                 root = rotateLeft(root, xpp);
    61                             }
    62                         }
    63                     }
    64                 }
    65             }
    66         }
    复制代码

    四、问题点解析

    1、扩容后,节点重hash为什么只可能分布在原索引位置与原索引+oldCap位置?

    假设老表的容量为16,即oldCap=16,则新表容量为16*2=32,假设节点1的hash值为0000 0000 0000 0000 0000 1111 0000 1010,节点2的hash值为0000 0000 0000 0000 0000 1111 0001 1010,则节点1和节点2在老表的索引位置计算如下图计算1,由于老表的长度限制,节点1和节点2的索引位置只取决于节点hash值的最后4位。再看计算2,计算2为新表的索引计算,可以知道如果两个节点在老表的索引位置相同,则新表的索引位置只取决于节点hash值倒数第5位的值,而此位置的值刚好为老表的容量值16,此时节点在新表的索引位置只有两种情况:原索引位置和原索引+oldCap位置(在此例中即为10和10+16=26)。由于结果只取决于节点hash值的倒数第5位,而此位置的值刚好为老表的容量值16,因此此时新表的索引位置的计算可以替换为计算3,直接使用节点的hash值与老表的容量16进行位于运算,如果结果为0则该节点在新表的索引位置为原索引位置,否则该节点在新表的索引位置为原索引+oldCap位置。

    2、死循环问题

    在Jdk 1.8以前,Java语言在并发情况下使用HashMap造成Race Condition,从而导致死循环。程序经常占了100%的CPU,查看堆栈,你会发现程序都Hang在了HashMap.get()这个方法上了,重启程序后问题消失。具体分析可以查看这篇文章:疫苗:JAVA HASHMAP的死循环,有人将这个问题当成一个bug提给了Sun,但是Sun认为这并不是个bug,因为HashMap本来就不保证并发的线程安全性,在并发下,要用ConcurrentHashMap来代替。
    Jdk 1.8以前,导致死循环的主要原因是扩容后,节点的顺序会反掉,扩容前节点A在节点C前面,而扩容后节点C在节点A前面。

    jdk1.8以后,扩容后,节点A和节点C的先后顺序跟扩容前是一样的。因此,即使此时有多个线程并发扩容,也不会出现死循环的情况。当然,这仍然改变不了HashMap仍是非并发安全,在并发下,还是要使用ConcurrentHashMap来代替。

    3、HashMap和Hashtable的区别

    HashMap允许key和value为null,Hashtable不允许。
    HashMap的默认初始容量为16,Hashtable为11。
    HashMap的扩容为原来的2倍,Hashtable的扩容为原来的2倍加1。
    HashMap是非线程安全的,Hashtable是线程安全的。
    HashMap的hash值重新计算过,Hashtable直接使用hashCode。
    HashMap去掉了Hashtable中的contains方法。
    HashMap继承自AbstractMap类,Hashtable继承自Dictionary类。

    出处:

    https://www.cnblogs.com/dennyzhangdd/p/6745282.html

    http://blog.csdn.net/u014026363/article/details/56342142

    http://www.cnblogs.com/skywang12345/p/3245399.html

    https://blog.csdn.net/v123411739/article/details/78996181

  • 相关阅读:
    学到的一种编程风格
    防止忘记初始化NSMutableArray的方法
    == 和 isEqualToString的区别之备忘
    IOS开发中--点击imageView上的Button没有任何反应
    [__NSCFString absoluteURL]错误的解决方案
    二叉树镜像
    判断树的子结构
    算法练习-贪心问题
    重建二叉树:
    IDEA搭建web项目出现中文乱码问题
  • 原文地址:https://www.cnblogs.com/wangzhongqiu/p/8618771.html
Copyright © 2020-2023  润新知