Hash

作者: BeYearn | 来源:发表于2019-01-12 12:10 被阅读0次

    哈希表就是 依据关键字可以根据一定的算法(哈希函数)映射到表中的特定位置 的思想建立的表。因此哈希表最大的特点就是可以根据f(K)函数得到其在数组中的索引。

    java官方说明中,Object的:

    • equals方法具有自反性、对称性、传递性、一致性,如果两个对象执行equals方法结果为true,则两对象的哈希码应该是相等的。 如果重写equals方法,则一定要重写hashcode方法。
    • hashcode方法是 返回对象的经过处理后的内存地址,由于每个对象的内存地址都不一样,所以哈希码也不一样。此方法为native方法,取决于JVM的内部设计,一般是某种C地址的偏移。
    • 文档中还给出了三条规定:
    1. 在对象没有被修改的前提下,执行多次调用,该hashCode方法必须始终返回相同的整数。
    2. 如果两个对象执行equals方法结果为true,则分别调用hashCode方法产生的整数结果是相等的。
    3. 非必要要求:两个对象执行equals方法结果为false,则分别调用hashCode方法产生的整数结果是不相等的。

    第三个要求虽然为非必需,但如果实现,则可以提高散列表的性能。

    Object的hash方法

    Object类的hashCode. 返回对象的经过处理后的内存地址,由于每个对象的内存地址都不一样,所以哈希码也不一样。这个是native方法,取决于JVM的内部设计,一般是某种C地址的偏移。

    String的equals和hashCode方法

    public int hashCode() {
            int h = hash;
            if (h == 0 && value.length > 0) {
                char val[] = value;
    
                for (int i = 0; i < value.length; i++) {
                    h = 31 * h + val[i];
                }
                hash = h;
            }
            return h;
        }
    

    该函数很简单,以31为权,每一位为字符的ASCII值进行运算,用自然溢出来等效取模,达到了目的——只要字符串的内容相同,返回的哈希码也相同。

    • 选31作为乘子, 它可以被JVM优化:31 * i = (i << 5) - i,并且是一个奇质数
    • unicode编码(万国码,原有的Ascii编码从单字节变成双字节,高位填0)固定占用两个字节,所以,java中char类型的变量也是占用两个字节。
    public boolean equals(Object anObject) {
            if (this == anObject) {
                return true;
            }
            if (anObject instanceof String) {
                String anotherString = (String)anObject;
                int n = value.length;
                if (n == anotherString.value.length) {
                    char v1[] = value;
                    char v2[] = anotherString.value;
                    int i = 0;
                    while (n-- != 0) {
                        if (v1[i] != v2[i])
                            return false;
                        i++;
                    }
                    return true;
                }
            }
            return false;
        }
    

    此equals方法包含了"==",双等号比较的是地址,存储地址相同,内容则相同。当地址不同的时候,先验证了比较对象是否为String,接着比较了两个字符串的长度,最后才循环比较每个字符是否相等。

    Integer的equals和hashCode方法

        @Override
        public int hashCode() {
            return Integer.hashCode(value);
        }
        //就是返回Integer对象里包含的那个整数的值
        public static int hashCode(int value) {
            return value;
        }
    
    public boolean equals(Object obj) {
            if (obj instanceof Integer) {
                return value == ((Integer)obj).intValue();
            }
            return false;
        }
    

    重写一个对象的hashcode

    1. 如果是boolean值,则计算 f ? 1:0;
    2. 如果是byte\char\short\int,则计算 (int)f;
    3. 如果是long值,则计算 (int)(f ^ (f >>> 32));
    4. 如果是float值,则计算 Float.floatToIntBits(f);
    5. 如果是double值,则计算 Double.doubleToLongBits(f),然后返回的结果是long,再用规则(3)去处理long,得到int;
    6. 如果是对象应用,如果equals方法中采取递归调用的比较方式,那么hashCode中同样采取递归调用hashCode的方式。否则需要为这个域计算一个范式,比如当这个域的值为null的时候,那么hashCode 值为0;
    7. 如果是数组,那么需要为每个元素当做单独的域来处理。java.util.Arrays.hashCode 方法包含了8种基本类型数组和引用数组的hashCode计算,算法同上。
      最后再将每个域的散列码相加

    哈希碰撞(hash冲突)

    1. 再哈希法,就是出现冲突后采用其他的哈希函数计算,直到不再冲突为止。
    2. 链接地址法,是在出现冲突的地方存储一个链表,所有的同义词记录都存在其中。形象点说就行像是在出现冲突的地方直接把后续的值摞上去。例如HashMap结构。

    这很容易理解,因为作为一种可用的散列算法,其位数一定是有限的,也就是说它能记录的文件是有限的——而文件数量是无限的,两个文件指纹发生碰撞的概率永远不会是零。

    哈希算法

    1. 求模(%) 但单纯使用求模算法计算之后的结果带有明显的规律性,这种规律将导致算法将能难保证不可逆性。
    2. 异或(^) 再配合移位等运算

    流行的hash算法包括 MD5(已被证明不具有强抗碰撞性)、SHA-1 和 SHA-256(碰撞时开销更大)

    一个小例子,拍卖会一个人只能出一次价,大家都出好好,价高者得,避免数据提交后有人知道后作弊:最高价+1 这种情况发生,每个人只需要提交自己出价的hash值即可,等到公布后再拿出原价与hash值对应即可。

    hash环(一致性hash算法)

    先构造一个长度为232的整数环(这个环被称为一致性Hash环),根据节点名称的Hash值(其分布为[0, 232-1])将服务器节点放置在这个Hash环上,然后根据数据的Key值计算得到其Hash值(其分布也为[0, 232-1]),接着在Hash环上顺时针查找距离这个Key值的Hash值最近的服务器节点,完成Key到服务器的映射查找。 ////这种算法解决了普通余数Hash算法伸缩性差的问题,可以保证在上线、下线服务器的情况下尽量有多的请求命中原来路由到的服务器。


    图片.png
    • list+排序
      算出所有节点的hash值放入数组或集合(方便扩展)中,从小到大排列;之后,等待路由的一方,算出自己的hash,然后从list中找到第一hash值大于自己的节点即可,倘若没有,意味此hash值大于所有节点的hash值,那么选list(0),即最小的那个即可。
    • 遍历+list
    1. 服务器节点不排序,其Hash值全部直接放入一个List中
    2. 待路由的节点,算出其Hash值,由于指明了"顺时针"(指的list的顺时针),因此遍历List,比待路由的节点Hash值大的,算出差值并记录,比待路由节点Hash值小的忽略。
    3. 算出所有的差值之后,最小的那个,就是最终需要路由过去的节点。
    • 二叉查找树
      当然我们不能简单地使用二叉查找树,因为可能出现不平衡的情况。平衡二叉查找树有AVL树、红黑树等,这里使用红黑树,选用红黑树的原因有两点:
    1. 红黑树主要的作用是用于存储有序的数据,这其实和第一种解决方案的思路又不谋而合了,但是它的效率非常高
    2. JDK里面提供了红黑树的代码实现TreeMap和TreeSet
      另外,以TreeMap为例,TreeMap本身提供了一个tailMap(K fromKey)方法,支持从红黑树中查找比fromKey大的值的集合,但并不需要遍历整个数据结构。
      使用红黑树,可以使得查找的时间复杂度降低为O(logN),比上面两种解决方案,效率大大提升。

    设置虚拟节点

    String重写的hashCode()方法在一致性Hash算法中实用价值不大,因为有时服务器节点ip间差别很小,String的hash计算方式导致其值也差距小。得找个算法重新计算HashCode。这种重新计算Hash值的算法有很多,比如CRC32_HASH、FNV1_32_HASH(计算效率会高一些)、KETAMA_HASH(是默认的MemCache推荐的一致性Hash算法)。

    /**
     * 带虚拟节点的一致性Hash算法
     * @author 五月的仓颉 http://www.cnblogs.com/xrq730/
     */
    public class ConsistentHashingWithVirtualNode
    {
        /**
         * 待添加入Hash环的服务器列表
         */
        private static String[] servers = {"192.168.0.0:111", "192.168.0.1:111", "192.168.0.2:111",
                "192.168.0.3:111", "192.168.0.4:111"};
        
        /**
         * 真实结点列表,考虑到服务器上线、下线的场景,即添加、删除的场景会比较频繁,这里使用LinkedList会更好
         */
        private static List<String> realNodes = new LinkedList<String>();
        
        /**
         * 虚拟节点,key表示虚拟节点的hash值,value表示虚拟节点的名称
         */
        private static SortedMap<Integer, String> virtualNodes = 
                new TreeMap<Integer, String>();
        
        /**
         * 虚拟节点的数目,这里写死,为了演示需要,一个真实结点对应5个虚拟节点
         */
        private static final int VIRTUAL_NODES = 5;
        
        static
        {
            // 先把原始的服务器添加到真实结点列表中
            for (int i = 0; i < servers.length; i++)
                realNodes.add(servers[i]);
            
            // 再添加虚拟节点,遍历LinkedList使用foreach循环效率会比较高
            for (String str : realNodes)
            {
                for (int i = 0; i < VIRTUAL_NODES; i++)
                {
                    String virtualNodeName = str + "&&VN" + String.valueOf(i);
                    int hash = getHash(virtualNodeName);
                    System.out.println("虚拟节点[" + virtualNodeName + "]被添加, hash值为" + hash);
                    virtualNodes.put(hash, virtualNodeName);
                }
            }
            System.out.println();
        }
        
        /**
         * 使用FNV1_32_HASH算法计算服务器的Hash值,这里不使用重写hashCode的方法,最终效果没区别 
         */
        private static int getHash(String str)
        {
            final int p = 16777619;
            int hash = (int)2166136261L;
            for (int i = 0; i < str.length(); i++)
                hash = (hash ^ str.charAt(i)) * p;
            hash += hash << 13;
            hash ^= hash >> 7;
            hash += hash << 3;
            hash ^= hash >> 17;
            hash += hash << 5;
            
            // 如果算出来的值为负数则取其绝对值
            if (hash < 0)
                hash = Math.abs(hash);
            return hash;
        }
        
        /**
         * 得到应当路由到的结点
         */
        private static String getServer(String node)
        {
            // 得到带路由的结点的Hash值
            int hash = getHash(node);
            // 得到大于该Hash值的所有Map
            SortedMap<Integer, String> subMap = 
                    virtualNodes.tailMap(hash);
            // 第一个Key就是顺时针过去离node最近的那个结点
            Integer i = subMap.firstKey();
            // 返回对应的虚拟节点名称,这里字符串稍微截取一下
            String virtualNode = subMap.get(i);
            return virtualNode.substring(0, virtualNode.indexOf("&&"));
        }
        
        public static void main(String[] args)
        {
            String[] nodes = {"127.0.0.1:1111", "221.226.0.1:2222", "10.211.0.1:3333"};
            for (int i = 0; i < nodes.length; i++)
                System.out.println("[" + nodes[i] + "]的hash值为" + 
                        getHash(nodes[i]) + ", 被路由到结点[" + getServer(nodes[i]) + "]");
        }
    }
    

    相关文章

      网友评论

          本文标题:Hash

          本文链接:https://www.haomeiwen.com/subject/fctfdqtx.html