面试套路问题总结2

158 阅读40分钟

1.ArrayList和LinkedList区别

ArrayList和LinkedList这两个都是List接口的实现类,两者都符合List接口特征允许存储重复元素,逻辑上是有序的,允许通过索引随机访问,但两者还是有区别:

对于存储空间上ArrayList是实现了基于数组的数据结构,数据元素保存在连继分配的内存,占用空间较小,LinkedList基于链表的数据结构,除了保存数据本身之外,还需要保存每个数据元素的前继和后继元素引用。占用内存空间较大。

集合框架系列教材(十三)- 关系与区别- ArrayList和LinkedList的区别详解

对于随机访问get和set,ArrayList性能上优于LinkedList,因为LinkedList要从表头开始搜索。

对于添加和删除操作add和remove,LinedList比较占优势,因为ArrayList要移动数据。

示例程序演示向两类列表对象中插入一条记录,性能上差别。

public class Test {
 
public static void main(String[] args) {
 
List aList=new ArrayList();
 
for(int i=0;i<1000000;i++){
 
//向aList中添加1000个字符串
 
aList.add(i+"");
 
}
 
List bList=new LinkedList();
 
for(int i=0;i<1000000;i++){
 
//向bListList中添加1000个字符串
 
bList.add(i+"");
 
}
 
long begin=System.currentTimeMillis();
 
aList.add(100,"List");
 
long end=System.currentTimeMillis();
 
System.out.println("ArrayList添加操作耗时:"+(end-begin));
 
begin=System.currentTimeMillis();
 
bList.add(100,"List");
 
end=System.currentTimeMillis();
 
System.out.println("LinkedList添加操作耗时:"+(end-begin));
 
}
 
}
 

2.hashmap、hashtable和currenthashmap的区别

前言

Map 这样的 Key Value 在软件开发中是非常经典的结构,常用于在内存中存放数据。

本篇主要想讨论 ConcurrentHashMap 这样一个并发容器,在正式开始之前我觉得有必要谈谈 HashMap,没有它就不会有后面的 ConcurrentHashMap。

Hashmap的结构,1.7和1.8有哪些区别

(1)JDK1.7用的是头插法,而JDK1.8及之后使用的都是尾插法,那么他们为什么要这样做呢?因为JDK1.7是用单链表进行的纵向延伸,当采用头插法时会容易出现逆序且环形链表死循环问题。但是在JDK1.8之后是因为加入了红黑树使用尾插法,能够避免出现逆序且链表死循环的问题。

(2)扩容后数据存储位置的计算方式也不一样:1. 在JDK1.7的时候是直接用hash值和需要扩容的二进制数进行&(这里就是为什么扩容的时候为啥一定必须是2的多少次幂的原因所在,因为如果只有2的n次幂的情况时最后一位二进制数才一定是1,这样能最大程度减少hash碰撞)(hash值 & length-1)

2、而在JDK1.8的时候直接用了JDK1.7的时候计算的规律,也就是扩容前的原始位置+扩容的大小值=JDK1.8的计算方式,而不再是JDK1.7的那种异或的方法。但是这种方式就相当于只需要判断Hash值的新增参与运算的位是0还是1就直接迅速计算出了扩容后的储存方式。

在计算hash值的时候,JDK1.7用了9次扰动处理=4次位运算+5次异或,而JDK1.8只用了2次扰动处理=1次位运算+1次异或。

扩容流程对比图:

(3)JDK1.7的时候使用的是数组+ 单链表的数据结构。但是在JDK1.8及之后时,使用的是数组+链表+红黑树的数据结构(当链表的深度达到8的时候,也就是默认阈值,就会自动扩容把链表转成红黑树的数据结构来把时间复杂度从O(n)变成O(logN)提高了效率)

(2)为什么在JDK1.8中进行对HashMap优化的时候,把链表转化为红黑树的阈值是8,而不是7或者不是20呢(面试蘑菇街问过)?

  • 如果选择6和8(如果链表小于等于6树还原转为链表,大于等于8转为树),中间有个差值7可以有效防止链表和树频繁转换。假设一下,如果设计成链表个数超过8则链表转换成树结构,链表个数小于8则树结构转换成链表,如果一个HashMap不停的插入、删除元素,链表个数在8左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低。
  • 还有一点重要的就是由于treenodes的大小大约是常规节点的两倍,因此我们仅在容器包含足够的节点以保证使用时才使用它们,当它们变得太小(由于移除或调整大小)时,它们会被转换回普通的node节点,容器中节点分布在hash桶中的频率遵循泊松分布,桶的长度超过8的概率非常非常小。所以作者应该是根据概率统计而选择了8作为阀值

1结构区别

Jdk1.8

HashMap1.8的底层数据结构是数组+链表+红黑树。

Jdk1.7

HashMap 1.7的底层数据结构是数组加链表

区别:

  • 一般情况下,以默认容量16为例,阈值等于12就扩容,单条链表能达到长度为8的概率是相当低的,除非Hash攻击或者HashMap容量过大出现某些链表过长导致性能急剧下降的问题,红黑树主要是为了结果这种问题。

  • 在正常情况下,效率相差并不大。

2.节点区别

区别:

Jdk1.8

  • hash是final修饰,也就是说hash值一旦确定,就不会再重新计算hash值了。

  • 新增了一个TreeNode节点,为了转换为红黑树。

Jdk1.7

  • hash是可变的,因为有rehash的操作。

HashMap 1.7

static class Entry<K,V> implements Map.Entry<K,V> {  
final K key;  
V value;  
Entry<K,V> next;  
int hash;  
}  

HashMap1.8

static class Node<K,V> implements Map.Entry<K,V> {  
final int hash;  
final K key;  
V value;  
Node<K,V> next;  
}  

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

3.Hash算法区别

区别

  • 1.8计算出来的结果只可能是一个,所以hash值设置为final修饰。
  • 1.7会先判断这Object是否是String,如果是,则不采用String复写的hashcode方法,处于一个Hash碰撞安全问题的考虑

Jdk1.7

final int hash(Object k) {  
int h = hashSeed;  
if (0 != h && k instanceof String) {  
return sun.misc.Hashing.stringHash32((String) k);  
}  
  
h ^= k.hashCode();  
  
// This function ensures that hashCodes that differ only by  
// constant multiples at each bit position have a bounded  
// number of collisions (approximately 8 at default load factor).  
h ^= (h >>> 20) ^ (h >>> 12);  
return h ^ (h >>> 7) ^ (h >>> 4);  
} 

Jdk1.8

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

4对Null的处理

Jdk1.7

Jdk1.7中,对null值做了单独的处理

简单的说,HashMap会遍历数组的下标为0的链表,循环找key=null的键,如果找到则替换。

如果当前数组下标为0的位置为空,即e==null,那么直接执行添加操作,key=null,插入位置为0。

public V put(K key, V value) {  
//判断是否是空值  
if (key == null)  
return putForNullKey(value);  
...  
} 

private V putForNullKey(V value) {  
for (Entry<K,V> e = table[0]; e != null; e = e.next) {  
if (e.key == null) {  
V oldValue = e.value;  
e.value = value;  
e.recordAccess(this);  
return oldValue;  
}  
}  
modCount++;  
addEntry(0, null, value, 0);  
return null;  
}  

Jdk1.8

而1.8中,由于Hash算法中会将null的hash值计算为0,插入时0&任何数都是0,插入位置为数组的下标为0的位置,所以我们可以认为,1.8中null为键和其他非null是一样的,也有hash值,也能别替换。只是计算结果为0而已。

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

区别

  • Jdk1.7中,null是一个特殊的值,单独处理
  • Jdk1.8中,null的hash值计算结果为0,其他地方和普通的key没区别。

5初始化的区别

我们常说Jdk1.8是懒加载,真的是这样吗?

Jdk1.8

transient Node<K,V>[] table;

构造方法

public HashMap(int initialCapacity, float loadFactor) {  
...  
this.loadFactor = loadFactor;  
this.threshold = tableSizeFor(initialCapacity);  
}  
  
public HashMap(int initialCapacity) {  
this(initialCapacity, DEFAULT_LOAD_FACTOR);  
}  
  
public HashMap() {  
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted  
} 

我们简答看一下tableSizeFor()方法,其实这个算法和Integer的highestOneBit()方法一样。

static final int tableSizeFor(int cap) {  
int n = cap - 1;  
n |= n >>> 1;  
n |= n >>> 2;  
n |= n >>> 4;  
n |= n >>> 8;  
n |= n >>> 16;  
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;  
} 

官方解释:Returns a power of two size for the given target capacity. (返回给定目标容量的二次幂。)

也就是获取比传入参数大的最小的2的N次幂。

比如:传入8,就返回8,传入9,就返回16.

Jdk1.7

Jdk1.7中,table在声明时就初始化为空表。

transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;

构造方法和Jdk1.8一致,但是没有立刻根据给定的初始容量去计算那个2的次幂。

public HashMap(int initialCapacity, float loadFactor) {  
...  
this.loadFactor = loadFactor;  
threshold = initialCapacity;  
}  
  
public HashMap(int initialCapacity) {  
this(initialCapacity, DEFAULT_LOAD_FACTOR);  
}  
  
public HashMap() {  
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);  
} 

我们可以看一下HashMap1.7的计算容量的方法

首先是put方法时,发现是空表,初始化。传入threshold,也就是我们之前传入的initCapactity自定义初始容量

public V put(K key, V value) {  
//判断是否是空表  
if (table == EMPTY_TABLE) {  
//初始化  
inflateTable(threshold);  
}  
...  
}  

这个方法也有官方的注释,意思就是找到大于等给定toSize的最小2的次幂

private void inflateTable(int toSize) {  
// Find a power of 2 >= toSize  
int capacity = roundUpToPowerOf2(toSize);  
...  
}  

我们发现,这个方法没有什么操作难度,是个人都可以写的出来

private static int roundUpToPowerOf2(int number) {  
// assert number >= 0 : "number must be non-negative";  
return number >= MAXIMUM_CAPACITY  
? MAXIMUM_CAPACITY  
: (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;  
}  

最终调用了Integer的计算2次幂的方法。

public static int highestOneBit(int i) {  
// HD, Figure 3-1  
i |= (i >>  1);  
i |= (i >>  2);  
i |= (i >>  4);  
i |= (i >>  8);  
i |= (i >> 16);  
return i - (i >>> 1);  
}  

和1.8的是一致的,但是我们阅读源码发现1.8更趋向于一个方法完成一个大的功能,比如putVal,resize,代码阅读性比较差,而1.7趋向于尽可能的方法拆分,提升阅读性,但是也增加了嵌套关系,结构复杂。

区别

Jdk1.7

  • table是直接赋值给了一个空数组,在第一次put元素时初始化和计算容量。

  • table是单独定义的inflateTable()初始化方法创建的。

Jdk1.8

  • 的table没有赋值,属于懒加载,构造方式时已经计算好了新的容量位置(大于等于给定容量的最小2的次幂)。
  • table是单独定义的inflateTable()初始化方法创建的。

Jdk1.8

扩容的区别总结

Jdk1.7:

  • 头插法,添加前先判断扩容,当前准备插入的位置不为空并且容量大于等于阈值才进行扩容,是两个条件
  • 扩容后可能会重新计算hash值。

Jdk1.8:

  • 尾插法,初始化时,添加节点结束之后和判断树化的时候都会去判断扩容。我们添加节点结束之后只要size大于阈值,就一定会扩容,是一个条件
  • 由于hash是final修饰,通过e.hash & oldCap==0来判断新插入的位置是否为原位置。

7节点插入的区别

区别

  • jdk1.7无论是resize的转移和新增节点createEntry,都是头插法
  • jdk1.8则都是尾插法,为什么这么做呢为了解决多线程的链表死循环问题。

究极总结

并发的HashMap为什么会引起死循环?

今天研读Java并发容器和框架时,看到为什么要使用ConcurrentHashMap时,其中有一个原因是:线程不安全的HashMap, HashMap在并发执行put操作时会引起死循环,是因为多线程会导致HashMap的Entry链表形成环形数据结构,查找时会陷入死循环。纠起原因看了其他的博客,都比较抽象,所以这里以图形的方式展示一下,希望支持!

(1)当往HashMap中添加元素时,会引起HashMap容器的扩容,原理不再解释,直接附源代码,如下:

/** 
    * 
    * 往表中添加元素,如果插入元素之后,表长度不够,便会调用resize方法扩容 
    */  
   void addEntry(int hash, K key, V value, int bucketIndex) {  
Entry<K,V> e = table[bucketIndex];  
       table[bucketIndex] = new Entry<K,V>(hash, key, value, e);  
       if (size++ >= threshold)  
           resize(2 * table.length);  
   }  
  
   /** 
    * resize()方法如下,重要的是transfer方法,把旧表中的元素添加到新表中
    */  
   void resize(int newCapacity) {  
       Entry[] oldTable = table;  
       int oldCapacity = oldTable.length;  
       if (oldCapacity == MAXIMUM_CAPACITY) {  
           threshold = Integer.MAX_VALUE;  
           return;  
       }  
  
       Entry[] newTable = new Entry[newCapacity];  
       transfer(newTable);  
       table = newTable;  
       threshold = (int)(newCapacity * loadFactor);  
   }  

(2)参考上面的代码,便引入到了transfer方法,(引入重点)这就是HashMap并发时,会引起死循环的根本原因所在,下面结合transfer的源代码,说明一下产生死循环的原理,先列transfer代码(这是里JDK7的源偌),如下:

/**
     * Transfers all entries from current table to newTable.
     */
    void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        for (Entry<K,V> e : table) {
 
            while(null != e) {
                Entry<K,V> next = e.next;            ---------------------(1)
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity); 
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            } // while
 
        }
    }

3)假设:

Map<Integer> map = new HashMap<Integer>(2);  // 只能放置两个元素,其中的threshold为1(表中只填充一个元素时),即插入元素为1时就扩容(由addEntry方法中得知)
//放置2个元素 3 和 7,若要再放置元素8(经hash映射后不等于1)时,会引起扩容

假设放置结果图如下:

现在有两个线程A和B,都要执行put操作,即向表中添加元素,即线程A和线程B都会看到上面图的状态快照

执行顺序如下:

执行一: 线程A执行到transfer函数中(1)处挂起(transfer函数代码中有标注)。此时在线程A的栈中

e = 3
next = 7

执行二:线程B执行 transfer函数中的while循环,即会把原来的table变成新一table(线程B自己的栈中),再写入到内存中。如下图(假设两个元素在新的hash函数下也会映射到同一个位置

执行三: 线程A解挂,接着执行(看到的仍是旧表),即从transfer代码(1)处接着执行,当前的 e = 3, next = 7, 上面已经描述。

1. 处理元素 3 , 将 3 放入 线程A自己栈的新table中(新table是处于线程A自己栈中,是线程私有的,不肥线程2的影响),处理3后的图如下:

2. 线程A再复制元素 7 ,当前 e = 7 ,而next值由于线程 B 修改了它的引用,所以next 为 3 ,处理后的新表如下图

3. 由于上面取到的next = 3, 接着while循环,即当前处理的结点为3, next就为null ,退出while循环,执行完while循环后,新表中的内容如下图:

4. 当操作完成,执行查找时,会陷入死循环!

总结

HashMap的方法不是线程安全的。HashMap在并发执行put操作时发生扩容,可能会导致节点丢失,产生环形链表等情况。

  • 节点丢失,会导致数据不准
  • 生成环形链表,会导致get()方法死循环。

HashMap

众所周知 HashMap 底层是基于 数组 + 链表 组成的,不过在 jdk1.7 和 1.8 中具体实现稍有不同。

Base 1.7

1.7 中的数据结构图:

先来看看 1.7 中的实现。

这是 HashMap 中比较核心的几个成员变量;看看分别是什么意思?

  1. 初始化桶大小,因为底层是数组,所以这是数组默认的大小。
  2. 桶最大值。
  3. 默认的负载因子(0.75)
  4. table 真正存放数据的数组。
  5. Map 存放数量的大小。
  6. 桶大小,可在初始化时显式指定。
  7. 负载因子,可在初始化时显式指定。

重点解释下负载因子:

由于给定的 HashMap 的容量大小是固定的,比如默认初始化:

public HashMap() {
    this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
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;
    threshold = initialCapacity;
    init();
}

给定的默认容量为 16,负载因子为 0.75。Map 在使用过程中不断的往里面存放数据,当数量达到了 16 * 0.75 = 12 就需要将当前 16 的容量进行扩容,而扩容这个过程涉及到 rehash、复制数据等操作,所以非常消耗性能。

因此通常建议能提前预估 HashMap 的大小最好,尽量的减少扩容带来的性能损耗。

根据代码可以看到其实真正存放数据的是

transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;

这个数组,那么它又是如何定义的呢?

Entry 是 HashMap 中的一个内部类,从他的成员变量很容易看出:

  • key 就是写入时的键。

  • value 自然就是值。

  • 开始的时候就提到 HashMap 是由数组和链表组成,所以这个 next 就是用于实现链表结构。

  • hash 存放的是当前 key 的 hashcode。

知晓了基本结构,那来看看其中重要的写入、获取函数:

put 方法

public V put(K key, V value) {
    if (table == EMPTY_TABLE) {
        inflateTable(threshold);
    }
    if (key == null)
        return putForNullKey(value);
    int hash = hash(key);
    int i = indexFor(hash, table.length);
    for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        Object k;
        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }
    modCount++;
    addEntry(hash, key, value, i);
    return null;
}
  • 判断当前数组是否需要初始化。

  • 如果 key 为空,则 put 一个空值进去。

  • 根据 key 计算出 hashcode。

  • 根据计算出的 hashcode 定位出所在桶。

  • 如果桶是一个链表则需要遍历判断里面的 hashcode、key 是否和传入 key 相等,如果相等则进行覆盖,并返回原来的值。

  • 如果桶是空的,说明当前位置没有数据存入;新增一个 Entry 对象写入当前位置。

    void addEntry(int hash, K key, V value, int bucketIndex) { if ((size >= threshold) && (null != table[bucketIndex])) { resize(2 * table.length); hash = (null != key) ? hash(key) : 0; bucketIndex = indexFor(hash, table.length); } createEntry(hash, key, value, bucketIndex); } void createEntry(int hash, K key, V value, int bucketIndex) { Entry<K,V> e = table[bucketIndex]; table[bucketIndex] = new Entry<>(hash, key, value, e); size++; }

当调用 addEntry 写入 Entry 时需要判断是否需要扩容。

如果需要就进行两倍扩充,并将当前的 key 重新 hash 并定位。

而在 createEntry 中会将当前位置的桶传入到新建的桶中,如果当前桶有值就会在位置形成链表。

get 方法

再来看看 get 函数:

public V get(Object key) {
    if (key == null)
        return getForNullKey();
    Entry<K,V> entry = getEntry(key);
    return null == entry ? null : entry.getValue();
}
final Entry<K,V> getEntry(Object key) {
    if (size == 0) {
        return null;
    }
    int hash = (key == null) ? 0 : hash(key);
    for (Entry<K,V> e = table[indexFor(hash, table.length)];
         e != null;
         e = e.next) {
        Object k;
        if (e.hash == hash &&
            ((k = e.key) == key || (key != null && key.equals(k))))
            return e;
    }
    return null;
}
  • 首先也是根据 key 计算出 hashcode,然后定位到具体的桶中。

  • 判断该位置是否为链表。

  • 不是链表就根据 key、key 的 hashcode 是否相等来返回值。

  • 为链表则需要遍历直到 key 及 hashcode 相等时候就返回值。

  • 啥都没取到就直接返回 null 。

Base 1.8

不知道 1.7 的实现大家看出需要优化的点没有?

其实一个很明显的地方就是:

当 Hash 冲突严重时,在桶上形成的链表会变的越来越长,这样在查询时的效率就会越来越低;时间复杂度为 O(N)。

因此 1.8 中重点优化了这个查询效率。

1.8 HashMap 结构图:

和 1.7 大体上都差不多,还是有几个重要的区别:

  • TREEIFY_THRESHOLD 用于判断是否需要将链表转换为红黑树的阈值。
  • HashEntry 修改为 Node。

Node 的核心组成其实也是和 1.7 中的 HashEntry 一样,存放的都是 key value hashcode next 等数据。

再来看看核心方法。

put 方法

看似要比 1.7 的复杂,我们一步步拆解:

  1. 判断当前桶是否为空,空的就需要初始化(resize 中会判断是否进行初始化)。
  2. 根据当前 key 的 hashcode 定位到具体的桶中并判断是否为空,为空表明没有 Hash 冲突就直接在当前位置创建一个新桶即可。
  3. 如果当前桶有值( Hash 冲突),那么就要比较当前桶中的 key、key 的 hashcode 与写入的 key 是否相等,相等就赋值给 e,在第 8 步的时候会统一进行赋值及返回。
  4. 如果当前桶为红黑树,那就要按照红黑树的方式写入数据。
  5. 如果是个链表,就需要将当前的 key、value 封装成一个新节点写入到当前桶的后面(形成链表)。
  6. 接着判断当前链表的大小是否大于预设的阈值,大于时就要转换为红黑树。
  7. 如果在遍历过程中找到 key 相同时直接退出遍历。
  8. 如果 e != null 就相当于存在相同的 key,那就需要将值覆盖。
  9. 最后判断是否需要进行扩容。

get 方法

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; Node<K,V> first, e; int n; K k;
    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))))
            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;
}

get 方法看起来就要简单许多了。

  • 首先将 key hash 之后取得所定位的桶。
  • 如果桶为空则直接返回 null 。
  • 否则判断桶的第一个位置(有可能是链表、红黑树)的 key 是否为查询的 key,是就直接返回 value。
  • 如果第一个不匹配,则判断它的下一个是红黑树还是链表。
  • 红黑树就按照树的查找方式返回值。
  • 不然就按照链表的方式遍历匹配返回值。

从这两个核心方法(get/put)可以看出 1.8 中对大链表做了优化,修改为红黑树之后查询效率直接提高到了 O(logn)

但是 HashMap 原有的问题也都存在,比如在并发场景下使用时容易出现死循环。

final HashMap<String, String> map = new HashMap<String, String>();
for (int i = 0; i < 1000; i++) {
    new Thread(new Runnable() {
        @Override
        public void run() {
            map.put(UUID.randomUUID().toString(), "");
        }
    }).start();
}

但是为什么呢?简单分析下。

看过上文的还记得在 HashMap 扩容的时候会调用 resize() 方法,就是这里的并发操作容易在一个桶上形成环形链表;这样当获取一个不存在的 key 时,计算出的 index 正好是环形链表的下标就会出现死循环。

如下图:

遍历方式

还有一个值得注意的是 HashMap 的遍历方式,通常有以下几种:

Iterator<Map.Entry<String, Integer>> entryIterator = map.entrySet().iterator();
        while (entryIterator.hasNext()) {
            Map.Entry<String, Integer> next = entryIterator.next();
            System.out.println("key=" + next.getKey() + " value=" + next.getValue());
        }
        
Iterator<String> iterator = map.keySet().iterator();
        while (iterator.hasNext()){
            String key = iterator.next();
            System.out.println("key=" + key + " value=" + map.get(key));
        }

强烈建议使用第一种 EntrySet 进行遍历。

第一种可以把 key value 同时取出,第二种还得需要通过 key 取一次 value,效率较低。

简单总结下 HashMap:无论是 1.7 还是 1.8 其实都能看出 JDK 没有对它做任何的同步操作,所以并发会出问题,甚至 1.7 中出现死循环导致系统不可用(1.8 已经修复死循环问题)。

因此 JDK 推出了专项专用的 ConcurrentHashMap ,该类位于 java.util.concurrent 包下,专门用于解决并发问题。

坚持看到这里的朋友算是已经把 ConcurrentHashMap 的基础已经打牢了,下面正式开始分析。

HashTable

java中HashMap和Hashtable的区别

HashMap:

(1)由数组+链表组成的,基于哈希表的Map实现,数组是HashMap的主体,

链表则是主要为了解决哈希冲突而存在的。

(2)不是线程安全的,HashMap可以接受为null的键(key)和值(value)。

(3)HashMap重新计算hash值

Hashtable:

(1)Hashtable 是一个散列表(哈希表),它存储的内容是键值对(key-value)映射。

(2)Hashtable 的函数都是同步的,这意味着它是线程安全的。它的key、value都不可以为null。

(3)HashTable直接使用对象的hashCode。

js数据结构-散列表(哈希表)_weixin_34186931的博客-CSDN博客

  • HashMap是线程不安全的,在多线程环境下会容易产生死循环,但是单线程环境下运行效率高;Hashtable线程安全的,很多方法都有synchronized修饰,但同时因为加锁导致单线程环境下效率较低。

  • HashMap允许有一个key为null,允许多个value为null;而Hashtable不允许key或者value为null。

  • HashTable有一个contains(Object value)功能和containsValue(Object value)功能一样。

  • 4.HashTable使用Enumeration进行遍历,HashMap使用Iterator进行遍历。以上只是表面的不同,它们的实现也有很大的不同。

  • 5.HashTable中hash数组默认大小是11,增加的方式是 old*2+1。HashMap中hash数组的默认大小是16,而且一定是2的指数。

  • 哈希值的使用不同,HashTable直接使用对象的hashCode,代码是这样的:

一般现在不建议用HashTable, ①是HashTable是遗留类,内部实现很多没优化和冗余。②即使在多线程环境下,现在也有同步的ConcurrentHashMap替代,没有必要因为是多线程而用HashTable。

ConcurrentHashMap具体是怎么实现线程安全的呢,肯定不可能是每个方法加synchronized,那样就变成了HashTable。

从ConcurrentHashMap代码中可以看出,它引入了一个“分段锁”的概念,具体可以理解为把一个大的Map拆分成N个小的HashTable,根据key.hashCode()来决定把key放到哪个HashTable中。

在ConcurrentHashMap中,就是把Map分成了N个Segment,put和get的时候,都是现根据key.hashCode()算出放到哪个Segment中:

ConcurrentHashMap

ConcurrentHashMap 同样也分为 1.7 、1.8 版,两者在实现上略有不同。

ConcurrentHashMap 如何做到高并发的
简单点说,使用了分段锁(分离锁)。每一把锁用于锁住容器中的一部分数据,减少线程间对锁的竞争。
这道题往深里问会死人的,篇幅有限,不啰嗦。

Base 1.7

先来看看 1.7 的实现,下面是他的结构图:

如图所示,是由 Segment 数组、HashEntry 组成,和 HashMap 一样,仍然是数组加链表。

它的核心成员变量

/**
 * Segment 数组,存放数据时首先需要定位到具体的 Segment 中。
 */
final Segment<K,V>[] segments;
transient Set<K> keySet;
transient Set<Map.Entry<K,V>> entrySet;

Segment 是 ConcurrentHashMap 的一个内部类,主要的组成如下:

static final class Segment<K,V> extends ReentrantLock implements Serializable {
       private static final long serialVersionUID = 2249069246763182397L;
       
       // 和 HashMap 中的 HashEntry 作用一样,真正存放数据的桶
       transient volatile HashEntry<K,V>[] table;
       transient int count;
       transient int modCount;
       transient int threshold;
       final float loadFactor;
       
}

看看其中 HashEntry 的组成:

和 HashMap 非常类似,唯一的区别就是其中的核心数据如 value ,以及链表都是 volatile 修饰的,保证了获取时的可见性。

原理上来说:ConcurrentHashMap 采用了分段锁技术,其中 Segment 继承于 ReentrantLock。不会像 HashTable 那样不管是 put 还是 get 操作都需要做同步处理,理论上 ConcurrentHashMap 支持 CurrencyLevel (Segment 数组数量)的线程并发。每当一个线程占用锁访问一个 Segment 时,不会影响到其他的 Segment。 

下面也来看看核心的 put get 方法。

put 方法

public V put(K key, V value) {
    Segment<K,V> s;
    if (value == null)
        throw new NullPointerException();
    int hash = hash(key);
    int j = (hash >>> segmentShift) & segmentMask;
    if ((s = (Segment<K,V>)UNSAFE.getObject          // nonvolatile; recheck
         (segments, (j << SSHIFT) + SBASE)) == null) //  in ensureSegment
        s = ensureSegment(j);
    return s.put(key, hash, value, false);
}

首先是通过 key 定位到 Segment,之后在对应的 Segment 中进行具体的 put。

final V put(K key, int hash, V value, boolean onlyIfAbsent) {
    HashEntry<K,V> node = tryLock() ? null :
        scanAndLockForPut(key, hash, value);
    V oldValue;
    try {
        HashEntry<K,V>[] tab = table;
        int index = (tab.length - 1) & hash;
        HashEntry<K,V> first = entryAt(tab, index);
        for (HashEntry<K,V> e = first;;) {
            if (e != null) {
                K k;
                if ((k = e.key) == key ||
                    (e.hash == hash && key.equals(k))) {
                    oldValue = e.value;
                    if (!onlyIfAbsent) {
                        e.value = value;
                        ++modCount;
                    }
                    break;
                }
                e = e.next;
            }
            else {
                if (node != null)
                    node.setNext(first);
                else
                    node = new HashEntry<K,V>(hash, key, value, first);
                int c = count + 1;
                if (c > threshold && tab.length < MAXIMUM_CAPACITY)
                    rehash(node);
                else
                    setEntryAt(tab, index, node);
                ++modCount;
                count = c;
                oldValue = null;
                break;
            }
        }
    } finally {
        unlock();
    }
    return oldValue;
}

虽然 HashEntry 中的 value 是用 volatile 关键词修饰的,但是并不能保证并发的原子性,所以 put 操作时仍然需要加锁处理。

首先第一步的时候会尝试获取锁,如果获取失败肯定就有其他线程存在竞争,则利用 scanAndLockForPut() 自旋获取锁。

  1. 尝试自旋获取锁。
  2. 如果重试的次数达到了 MAX_SCAN_RETRIES 则改为阻塞锁获取,保证能获取成功。

再结合图看看 put 的流程。

  1. 将当前 Segment 中的 table 通过 key 的 hashcode 定位到 HashEntry。
  2. 遍历该 HashEntry,如果不为空则判断传入的 key 和当前遍历的 key 是否相等,相等则覆盖旧的 value。
  3. 不为空则需要新建一个 HashEntry 并加入到 Segment 中,同时会先判断是否需要扩容。
  4. 最后会解除在 1 中所获取当前 Segment 的锁。

get 方法

public V get(Object key) {
    Segment<K,V> s; // manually integrate access methods to reduce overhead
    HashEntry<K,V>[] tab;
    int h = hash(key);
    long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
    if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
        (tab = s.table) != null) {
        for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile
                 (tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);
             e != null; e = e.next) {
            K k;
            if ((k = e.key) == key || (e.hash == h && key.equals(k)))
                return e.value;
        }
    }
    return null;
}

get 逻辑比较简单:

只需要将 Key 通过 Hash 之后定位到具体的 Segment ,再通过一次 Hash 定位到具体的元素上。

由于 HashEntry 中的 value 属性是用 volatile 关键词修饰的,保证了内存可见性,所以每次获取时都是最新值。

ConcurrentHashMap 的 get 方法是非常高效的,因为整个过程都不需要加锁

Base 1.8

1.7 已经解决了并发问题,并且能支持 N 个 Segment 这么多次数的并发,但依然存在 HashMap 在 1.7 版本中的问题。

那就是查询遍历链表效率太低。

因此 1.8 做了一些数据结构上的调整。

首先来看下底层的组成结构:

看起来是不是和 1.8 HashMap 结构类似?

其中抛弃了原有的 Segment 分段锁,而采用了 CAS + synchronized 来保证并发安全性。

也将 1.7 中存放数据的 HashEntry 改为 Node,但作用都是相同的。

其中的 val next 都用了 volatile 修饰,保证了可见性。

put 方法

重点来看看 put 函数:

  • 根据 key 计算出 hashcode 。

  • 判断是否需要进行初始化。

  • f 即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功。

  • 如果当前位置的 hashcode == MOVED == -1,则需要进行扩容。

  • 如果都不满足,则利用 synchronized 锁写入数据。

  • 如果数量大于 TREEIFY_THRESHOLD 则要转换为红黑树。

get 方法

  • 根据计算出来的 hashcode 寻址,如果就在桶上那么直接返回值。

  • 如果是红黑树那就按照树的方式获取值。

  • 就不满足那就按照链表的方式遍历获取值。

1.8 在 1.7 的数据结构上做了大的改动,采用红黑树之后可以保证查询效率(O(logn)),甚至取消了 ReentrantLock 改为了 synchronized,这样可以看出在新版的 JDK 中对 synchronized 优化是很到位的。

总结

其实可以看出JDK1.8版本的ConcurrentHashMap的数据结构已经接近HashMap,相对而言,ConcurrentHashMap只是增加了同步的操作来控制并发,从JDK1.7版本的ReentrantLock+Segment+HashEntry,到JDK1.8版本中synchronized+CAS+HashEntry+红黑树。

1.数据结构:取消了Segment分段锁的数据结构,取而代之的是数组+链表+红黑树的结构。

2.保证线程安全机制:JDK1.7采用segment的分段锁机制实现线程安全,其中segment继承自ReentrantLock。JDK1.8采用CAS+Synchronized保证线程安全。

3.锁的粒度:原来是对需要进行数据操作的Segment加锁,现调整为对每个数组元素加锁(Node)。

4.链表转化为红黑树:定位结点的hash算法简化会带来弊端,Hash冲突加剧,因此在链表节点数量大于8时,会将链表转化为红黑树进行存储。

5.查询时间复杂度:从原来的遍历链表O(n),变成遍历红黑树O(logN)。

2.Dubbo底层实现原理和机制

Dubbo :是一个rpc框架,soa框架

面向服务的架构(SOA)是一个组件模型,它将应用程序的不同功能单元(称为服务)进行拆分,并通过这些服务之间定义良好的接口和协议联系起来。 接口是采用中立的方式进行定义的,它应该独立于实现服务的硬件平台、操作系统和编程语言。 这使得构建在各种各样的系统中的服务可以以一种统一和通用的方式进行交互。

实际上SOA只是一种架构设计模式,而SOAP、REST、RPC就是根据这种设计模式构建出来的规范,其中SOAP通俗理解就是http+xml的形式,REST就是http+json的形式,RPC是基于socket的形式。上文提到的CXF就是典型的SOAP/REST框架,dubbo就是典型的RPC框架,而SpringCloud就是遵守REST规范的生态系统。 

作为RPC:支持各种传输协议,如dubbo,hession,json,fastjson,底层采用mina,netty长连接进行传输!典型的provider和cusomer模式! 

作为SOA:具有服务治理功能,提供服务的注册和发现!用zookeeper实现注册中心!启动时候服务端会把所有接口注册到注册中心,并且订阅configurators,服务消费端订阅provide,configurators,routers,订阅变更时,zk会推送providers,configuators,routers,启动时注册长连接,进行通讯!proveider和provider启动后,后台启动定时器,发送统计数据到monitor!提供各种容错机制和负载均衡策略!! 

Dubbo****支持的协议

在通信过程中,不同的服务等级一般对应着不同的服务质量,那么选择合适的协议便是一件非常重要的事情。你可以根据你应用的创建来选择。例如,使用RMI协议,一般会受到防火墙的限制,所以对于外部与内部进行通信的场景,就不要使用RMI协议,而是基于HTTP协议或者Hessian协议。Dubbo支持8种左右的协议,如下所示: 

  • (1) dubbo:// Dubbo协议

  • (2) rmi:// RMI协议

  • (3) hessian:// Hessian协议

  • (4) http:// HTTP协议

  • (5) webservice:// WebService协议

  • (6) thrift:// Thrift协议

  • (7) memcached:// Memcached协议

  • (8)redis:// Redis协议

在通信过程中,不同的服务等级一般对应着不同的服务质量,那么选择合适的协议便是一件非常重要的事情。你可以根据你应用的创建来选择。例如,使用RMI协议,一般会受到防火墙的限制,所以对于外部与内部进行通信的场景,就不要使用RMI协议,而是基于HTTP协议或者Hessian协议。

部分协议的特点和使用场景如下:

1、dubbo协议

Dubbo缺省协议采用单一长连接和NIO异步通讯,适合于小数据量大并发的服务调用,以及服务消费者机器数远大于服务提供者机器数的情况。

缺省协议,使用基于mina1.1.7+hessian3.2.1的tbremoting交互。
连接个数:单连接
连接方式:长连接
传输协议:TCP
传输方式:NIO异步传输
序列化:Hessian二进制序列化
适用范围:传入传出参数数据包较小(建议小于100K),消费者比提供者个数多,单一消费者无法压满提供者,尽量不要用dubbo协议传输大文件或超大字符串。
适用场景:常规远程服务方法调用

为什么要消费者比提供者个数多:
因dubbo协议采用单一长连接,
假设网络为千兆网卡(1024Mbit=128MByte),
根据测试经验数据每条连接最多只能压满7MByte(不同的环境可能不一样,供参考),
理论上1个服务提供者需要20个服务消费者才能压满网卡。
 
为什么不能传大包:
因dubbo协议采用单一长连接,
如果每次请求的数据包大小为500KByte,假设网络为千兆网卡(1024Mbit=128MByte),每条连接最大7MByte(不同的环境可能不一样,供参考),
单个服务提供者的TPS(每秒处理事务数)最大为:128MByte / 500KByte = 262。
单个消费者调用单个服务提供者的TPS(每秒处理事务数)最大为:7MByte / 500KByte = 14。
如果能接受,可以考虑使用,否则网络将成为瓶颈。
 
为什么采用异步单一长连接:
因为服务的现状大都是服务提供者少,通常只有几台机器,
而服务的消费者多,可能整个网站都在访问该服务,
比如Morgan的提供者只有6台提供者,却有上百台消费者,每天有1.5亿次调用,
如果采用常规的hessian服务,服务提供者很容易就被压跨,
通过单一连接,保证单一消费者不会压死提供者,
长连接,减少连接握手验证等,
并使用异步IO,复用线程池,防止C10K问题。

 2、RMI

RMI协议采用JDK标准的java.rmi.*实现,采用阻塞式短连接和JDK标准序列化方式

Java标准的远程调用协议。
连接个数:多连接
连接方式:短连接
传输协议:TCP
传输方式:同步传输
序列化:Java标准二进制序列化
适用范围:传入传出参数数据包大小混合,消费者与提供者个数差不多,可传文件。
适用场景:常规远程服务方法调用,与原生RMI服务互操作

 3、hessian

Hessian协议用于集成Hessian的服务,Hessian底层采用Http通讯,采用Servlet暴露服务,Dubbo缺省内嵌Jetty作为服务器实现

基于Hessian的远程调用协议。
 
连接个数:多连接
连接方式:短连接
传输协议:HTTP
传输方式:同步传输
序列化:Hessian二进制序列化
适用范围:传入传出参数数据包较大,提供者比消费者个数多,提供者压力较大,可传文件。
适用场景:页面传输,文件传输,或与原生hessian服务互操作

4、http

采用Spring的HttpInvoker实现

基于http表单的远程调用协议。
 
连接个数:多连接
连接方式:短连接
传输协议:HTTP
传输方式:同步传输
序列化:表单序列化(JSON)
适用范围:传入传出参数数据包大小混合,提供者比消费者个数多,可用浏览器查看,可用表单或URL传入参数,暂不支持传文件。
适用场景:需同时给应用程序和浏览器JS使用的服务。

5、webservice

基于CXF的frontend-simple和transports-http实现

基于WebService的远程调用协议。
 
连接个数:多连接
连接方式:短连接
传输协议:HTTP
传输方式:同步传输
序列化:SOAP文本序列化
适用场景:系统集成,跨语言调用。

6、thrif

Thrift是Facebook捐给Apache的一个RPC框架,当前 dubbo 支持的 thrift 协议是对 thrift 原生协议的扩展,在原生协议的基础上添加了一些额外的头信息,比如service name,magic number等。

rpc通信协议http和thrift之间的区别

使用thrift等工具可以实现二进制传输,相比http的文传输无疑大大提高了传输效率;http通常使用的json,需要用户序列化/反序列化,性能和复杂度高。相比之下,Thrift等工具,使用了成熟的代码生成技术,将通信接口的IDL文件(IDL(Interface Definition Language)即接口定义语言)生成了对应语言的代码接口,实现了远程调用接近于本地方法的调用。另外无论是网络传输编码、解码,还是传输内容大小还是网络开销都相比http有较大的优势,另外一个企业内部的开发语言、框架不会有太大的异构性,http并无优势。 

服务暴露和消费的详细过程

(1)服务提供者暴露一个服务的详细过程

    (1)暴露本地服务

(2)暴露远程服务

(3)启动Netty

(4)连接到Zookeeper服务器

(5)到Zookeeper注册

(6)监听Zookeeper服务器

  1. 其实第一步就是根据我们在XMl中配置的文件,对<dubbo: >标签的bean进行解析,然后注册的Spring容器中,供后面使用
  2. 然后当Spring 容器发布刷新事件的时候,会触发 ServiceBean 的 onApplicationEvent()方法,然后执行方法中的export()方法,这个方法是真正dubbo开始暴露本地方法的开始
  3. 然后进入export()之后是检查,没有的问题的话,就执行doExport()方法,然后又是一顿检查,检查提供者是不是为空、有没有初始化、接口名称是不是合法,然后没问题的话,再去执行 doExportUrls()方法,暴露服务。
  4. 进入 doExportUrls()方法之后,然后是加载注册中心的链接,遍历是那种调用方式(比如dubbo协议、dubbo直连),然后下一步是调用这个方法doExportUrlsFor1Protocol()组装URL。
  5. doExportUrlsFor1Protocol()方法中的这个过程的核心就是为了让从本地过来的一些核心参数转换为URL,这样更方便在网络中进行传输,比如把版本、时间戳、方法名以及各种配置对象的字段信息、上下文路径、主机名以及端口号等信息都放到URL
  6. 然后是继续执行这个方法doExportUrlsFor1Protocol(ProtocolConfig protocolConfig, List registryURLs)的第二个重要的部分,把服务导出到本地JVM还是远程,会根据URL中scope参数决定导出服务的方式scope = none,不导出服务scope != remote,导出到本地scope != local,导出到远程。 
  7. 然后是由ProxyFactory的一个代理工厂调用getInvoker(T proxy, Class type, URL url)方法来创建一个Invoker。其实这个Invoker就是一个包含了本地要暴露的代理对象(比如代理对象生成的代理类UserServiceImpl)、类型信息和上一步包装好的URL信息,就是一个执行者的完整信息对象,然后在进行包装成wrapperInvoker对象进行暴露出去,下一步调用export()方法 
  8. 然后是通过Protocol.export(wrapperInvoker)把这个完成的Invoker对象暴露出去,这里使用了Java的SPI机制进行获取到的Protocol对像。只要一调用exporter就会生成一个Exporter对象,然后会使用下面的DubboProtocol对象进行暴露(先拿到注册中心的URL地址),其实也就是在进行一次Export()调用,然后会得到一个DubboExporter对象,使用是dubbo协议 

在这里插入图片描述

9.然后通过DubboExporter去进行调用openServer()方法去开启服务器,因为刚开始没有服务器,所以需要创建一个ExchangeServer对象,然后继续进行通过bind()方法进行绑定对应的url。(然后一直进去发现其实调用的还是Netty的底层实现),然后启动服务器,监听端口

10.然后是把提供者的注册信息注册到ProviderConsumerRegTable对象中进行维护。

在这里插入图片描述

服务提供者暴露服务的主过程:

首先ServiceConfig类拿到对外提供服务的实际类ref(如:HelloWorldImpl),然后通过ProxyFactory类的getInvoker方法使用ref生成一个AbstractProxyInvoker实例,

到这一步就完成具体服务到Invoker的转化。接下来就是Invoker转换到Exporter的过程。

Dubbo处理服务暴露的关键就在Invoker转换到Exporter的过程(如上图中的红色部分),下面我们以Dubbo和RMI这两种典型协议的实现来进行说明:

Dubbo的实现:

Dubbo协议的Invoker转为Exporter发生在DubboProtocol类的export方法,它主要是打开socket侦听服务,并接收客户端发来的各种请求,通讯细节由Dubbo自己实现。

RMI的实现:

RMI协议的Invoker转为Exporter发生在RmiProtocol类的export方法,

它通过Spring或Dubbo或JDK来实现RMI服务,通讯细节这一块由JDK底层来实现,这就省了不少工作量。

2)服务消费者消费一个服务的详细过程

服务消费的主过程:

(一)大概描述一下整个服务调用的过程的准备工作和总体流程,后面一步一步分析

先说一下dubbo调用服务的原理需要的前置步骤,相当于准备工作:

  • dubbo在引用服务的时候是分两种方式的,第一种是我们正常使用的懒汉式的加载方式的,其实就是我们用@Reference注解的时候使用的就是懒汉式的加载方式

  • 第二种是当我们在 Spring 容器调用 ReferenceBean 的 afterPropertiesSet 方法时引用服务,这种调用方式其实是一种饿汉式的加载方式,然后两者调用的开始其实就是从ReferenceBean 的 getObject 方法开始

现在调用完事之后,下一步就是决定引用那种服务,有三种可供选择的嗲用服务的方式:

  1. 第一种是引用本地 (JVM) 服务

  2. 第二是通过直连方式引用远程服务

  3. 第三是通过注册中心引用远程服务

  4. 然后最终都会生成一个Invoker对象,此时的Invoker对象已经具备调用本地或远程服务的能力了,但是不能直接暴露给用户,会对一下业务造成侵入,然后需要使用代理工厂类代理对象去调用Invoker的逻辑。

总的概括一下总的调用流程:

首先是ReferenceConfig类先去调用init()方法,然后会在init方法中调用Protocol的refer()方法生成一个Invoker对象实例,这里是消费服务的关键,然后会在init方法中通过createProxy(map)方法生成一个代理对象,然后在CreateProxy方法中进行判断是采用哪种调用方式(就是上面三种方式之一),并去调用refer()方法生成一个Invoker对象,接下来会把Invoker对象转换为客服端需要的对象(UserService), 

首先ReferenceConfig类的init方法调用Protocol的refer方法生成Invoker实例(如上图中的红色部分),这是服务消费的关键。

接下来把Invoker转换为客户端需要的接口(如:HelloWorld)。

Dubbo的远程服务调用

(1)首选Dubbo是通过Poxy对象来生成一个代理对象的

  1. 具体实现是在ReferenceConfig对象中调用的private T createProxy(Map<String, String> map)方法的,这个方法中有三种生成Invoker对象的方式,第一种是通过本地JVM,第二种是通过URL对象是不是为空判断进行判断,然后如果为空就从注册中心获取这个Invoker对象,否则就是从ReferenceConfig中的URL中拿到 

  2. 上面那个方法中还会通过获取到的Invoker这里的【生成Invoker的过程后面补充】的对象去通过ProxyFactory生成Poxy对象,代码为:return proxyFactory.getProxy(this.invoker);,这里proxyFactory其实就是

    //ProxyFactory接口的javassist扩展类JavassistProxyFactory的getProxy方法实现
    

    public T getProxy(Invoker invoker, Class<?>[] interfaces) { return Proxy.getProxy(interfaces).newInstance(new InvokerInvocationHandler(invoker)); }

  3. 然后通过第2步的getPoxy()方法去动态代理生成代理Poxy对象

    public static Proxy getProxy(Class<?>... ics) {
        return getProxy(ClassHelper.getClassLoader(Proxy.class), ics);
    }
    

    /** * Get proxy. * * @param cl class loader. * @param ics interface class array. 可以实现多个接口 * @return Proxy instance. */ public static Proxy getProxy(ClassLoader cl, Class<?>... ics) { if (ics.length > 65535) throw new IllegalArgumentException("interface limit exceeded");

        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < ics.length; i++) {
            String itf = ics[i].getName();
            if (!ics[i].isInterface())
                throw new RuntimeException(itf + " is not a interface.");
    
            Class<?> tmp = null;
            try {
                tmp = Class.forName(itf, false, cl);
            } catch (ClassNotFoundException e) {
            }
    
            if (tmp != ics[i])
                throw new IllegalArgumentException(ics[i] + " is not visible from class loader");
    
            sb.append(itf).append(';');
        }
    
        // use interface class name list as key.
        // 用接口类名做key,多个接口以分号分开。
        String key = sb.toString();
    
        // get cache by class loader.
        // 缓存
        Map<String, Object> cache;
        synchronized (ProxyCacheMap) {
            cache = ProxyCacheMap.get(cl);
            if (cache == null) {
                cache = new HashMap<String, Object>();
                ProxyCacheMap.put(cl, cache);
            }
        }
    
        Proxy proxy = null;
        synchronized (cache) {
            do {
                Object value = cache.get(key);
                if (value instanceof Reference<?>) {
                    //如果有存在引用对象,返回缓存对象。
                    proxy = (Proxy) ((Reference<?>) value).get();
                    if (proxy != null)
                        return proxy;
                }
                //对象正在生成,线程挂起,等待
                if (value == PendingGenerationMarker) {
                    try {
                        cache.wait();
                    } catch (InterruptedException e) {
                    }
                } else {//放入正在生成标识
                    cache.put(key, PendingGenerationMarker);
                    break;
                }
            }
            while (true);
        }
        //类名称后自动加序列号 0,1,2,3...
        long id = PROXY_CLASS_COUNTER.getAndIncrement();
        String pkg = null;
        //ClassGenerator dubbo用javassist实现的工具类
        ClassGenerator ccp = null, ccm = null;
        try {
            ccp = ClassGenerator.newInstance(cl);
    
            Set<String> worked = new HashSet<String>();
            List<Method> methods = new ArrayList<Method>();
    
            for (int i = 0; i < ics.length; i++) {
                //检查包名称及不同包的修饰符
                if (!Modifier.isPublic(ics[i].getModifiers())) {
                    String npkg = ics[i].getPackage().getName();
                    if (pkg == null) {
                        pkg = npkg;
                    } else {
                        if (!pkg.equals(npkg))
                            throw new IllegalArgumentException("non-public interfaces from different packages");
                    }
                }
                //代理类添加要实现的接口Class对象
                ccp.addInterface(ics[i]);
    
                for (Method method : ics[i].getMethods()) {
                    //获取方法描述符,不同接口,同样的方法,只能被实现一次。
                    String desc = ReflectUtils.getDesc(method);
                    if (worked.contains(desc))
                        continue;
                    worked.add(desc);
    
                    int ix = methods.size();
                    //方法返回类型
                    Class<?> rt = method.getReturnType();
                    //方法参数类型列表
                    Class<?>[] pts = method.getParameterTypes();
                    //生成接口的实现代码,每个方法都一样
                    StringBuilder code = new StringBuilder("Object[] args = new Object[").append(pts.length).append("];");
                    for (int j = 0; j < pts.length; j++)
                        code.append(" args[").append(j).append("] = ($w)$").append(j + 1).append(";");
                    code.append(" Object ret = handler.invoke(this, methods[" + ix + "], args);");
                    if (!Void.TYPE.equals(rt))
                        code.append(" return ").append(asArgument(rt, "ret")).append(";");
    
                    methods.add(method);
                    ccp.addMethod(method.getName(), method.getModifiers(), rt, pts, method.getExceptionTypes(), code.toString());
                }
            }
    
            if (pkg == null)
                pkg = PACKAGE_NAME;
    
            // create ProxyInstance class.
            // 具体代理类名称,这里是类全名
            String pcn = pkg + ".proxy" + id;
            ccp.setClassName(pcn);
            ccp.addField("public static java.lang.reflect.Method[] methods;");
            ccp.addField("private " + InvocationHandler.class.getName() + " handler;");
            //创建构造函数
            ccp.addConstructor(Modifier.PUBLIC, new Class<?>[]{InvocationHandler.class}, new Class<?>[0], "handler=$1;");
            ccp.addDefaultConstructor();
            Class<?> clazz = ccp.toClass();
            //通过反射,把method数组放入,静态变量methods中,
            clazz.getField("methods").set(null, methods.toArray(new Method[0]));
    
            // create Proxy class.
            String fcn = Proxy.class.getName() + id;
            ccm = ClassGenerator.newInstance(cl);
            ccm.setClassName(fcn);
            ccm.addDefaultConstructor();
        //设置父类为抽象类,Proxy类子类,
            ccm.setSuperClass(Proxy.class);
        //生成实现它的抽象方法newInstance代码
        //new 的实例对象,是上面生成的代理类 pcn
            ccm.addMethod("public Object newInstance(" + InvocationHandler.class.getName() + " h){ return new " + pcn + "($1); }");
            Class<?> pc = ccm.toClass();
            proxy = (Proxy) pc.newInstance();
        } catch (RuntimeException e) {
            throw e;
        } catch (Exception e) {
            throw new RuntimeException(e.getMessage(), e);
        } finally {
            // release ClassGenerator
            if (ccp != null)
                ccp.release();
            if (ccm != null)
                ccm.release();
            synchronized (cache) {
                if (proxy == null)
                    cache.remove(key);
                else
                    //放入缓存,key:实现的接口名,value 代理对象,这个用弱引用,
                    //当jvm gc时,会打断对实例对象的引用,对象接下来就等待被回收。
                    cache.put(key, new WeakReference<Proxy>(proxy));
                cache.notifyAll();
            }
        }
        return proxy;
    }
    

(2)这里是进行补充上面的那个【Invoker的怎么生成的步骤】,看一下Invoker中都包含了什么信息这么重要,这里需要强调一下这个Invoker生成的过程和Dubbo服务的暴露和导出生成的Invoker不太一样

**1.**invoker对象是通过 InvokerInvocationHandler构造方法传入,而InvokerInvocationHandler对象是由JavassistProxyFactory类getProxy(Invoker invoker, Class<?>[] interfaces)方法创建。 这还要回到调用proxyFactory.getProxy(invoker);方法的地方,即ReferenceConfig类的createProxy(Map<String, String> map)方法中

2.所以这个Invoker其实是通过ReferenceConfig 中的createProxy(Map<String, String> map)方法来生成的Invoker对象,这个就是下面中使用到的对象refprotocol ,private static final Protocol refprotocol = (Protocol)ExtensionLoader.getExtensionLoader(Protocol.class).getAdaptiveExtension();

 if (urls.size() == 1) {//只有一个直连地址或一个注册中心配置地址
	//这里的urls.get(0)协议,可能是直连地址(默认dubbo协议),也可能是regiter注册地址(zookeeper协议)
	//我们这里走的是注册中心,所以
	invoker = refprotocol.refer(interfaceClass, urls.get(0));//本例通过配置一个注册中心的形式(***看这里***)
    } else {//多个直连地址或者多个注册中心地址,甚至是两者的组合。
	List<Invoker<?>> invokers = new ArrayList<Invoker<?>>();
	URL registryURL = null;
	for (URL url : urls) {
	    //创建invoker放入invokers
	    invokers.add(refprotocol.refer(interfaceClass, url));
	    if (Constants.REGISTRY_PROTOCOL.equals(url.getProtocol())) {
		registryURL = url; // 多个注册中心,用最后一个registry url
	    }
	}
	if (registryURL != null) { //有注册中心协议的URL,
	    //对多个url,其中存在有注册中心的,写死用AvailableCluster集群策略
	    //这其中包括直连和注册中心混合或者都是注册中心两种情况
	    URL u = registryURL.addParameter(Constants.CLUSTER_KEY, AvailableCluster.NAME);
	    invoker = cluster.join(new StaticDirectory(u, invokers));
	} else { // 多个直连的url
	    invoker = cluster.join(new StaticDirectory(invokers));
	}
    }

上面的代码可以看出生成Invoker的有三种方式,

  • 第一种是refprotocol.refer(this.interfaceClass, (URL),这里的接口用的是SPI机制的dubbo对应下面的那个@SPI注解=com.alibaba.dubbo.rpc.protocol.dubbo.DubboProtocol这一句,所以这一种主要是使用的是Dubbo直连的协议都会走这一层

  • 第二种是 cluster.join(new StaticDirectory(u, invokers));,这个是加了路由的负载均衡相关的,这一部分主要都是路由层的东西

  • 第三种是 this.invoker = cluster.join(new StaticDirectory(invokers));也是路由层的东西, 路由层:封装多个提供者的路由及负载均衡,并桥接注册中心,以 Invoker 为中心,扩展接口为 Cluster, Directory, Router, LoadBalance 

    @SPI("dubbo") public interface Protocol { int getDefaultPort();

    @Adaptive
    <T> Exporter<T> export(Invoker<T> var1) throws RpcException;
    
    @Adaptive
    <T> Invoker<T> refer(Class<T> var1, URL var2) throws RpcException;
    
    void destroy();
    

    }

因为Dubbo中在实现远程调用的时候其实是通过Poxy对象生成的Invoker对象,那么就先看一下Invoker的怎么生成的,里面都包含了什么信息?

  • 这里的invoker对象,通过InvokerInvocationHandler构造方法传入,而InvokerInvocationHandler对象是由JavassistProxyFactory类 getProxy(Invoker invoker, Class<?>[] interfaces)方法创建

 Dubbo的SPI机制的底层是如何实现的?

(一)什么是SPI机制?Java中的SPI机制是如何实现的?

(1)首先先说一下JavaSPI机制(Service Provider Interface)其实说白了就是定义一个接口,但是可以有多个实现该接口的实现类,其实也是一种服务发现机制。

  • 其实SPI机制的本质就是将接口实现类的全限定名配置在文件中,并由服务加载器读取配置文件,加载实现类。这样可以在运行时,动态为接口替换实现类

(2)那么Dubbo中的SPI机制和Java中的SPi机制又有什么区别呢?

  • 首先Java中的SPI机制是基于接口的编程+策略模式+配置文件的组合方式实现的动态加载机制

  • 其实具体的JavaSPI的实现就是:

  • 1.你先定义一个A接口,比如是com.wangwei.A这个接口

  • 2.然后你需要在src/main/resources/ 下建立 /META-INF/services 目录下定义和A接口相同的文件com.wangwei.A名字,然后需要在这个com.wangwei.A文件中 定义一些你需要实现这个接口的实现类这里有多个实现类的

  • 3.然后你在使用的时候需要去遍历这个接口的所有实现类,然后拿到你想要拿到的实现类,然后使用,是这样的一个流程。

在这里插入图片描述

(二)dubbo的SPI机制底层是如何实现的?

(1)dubbo中的SPI机制

  • 其实为什么在dubbo中又重新定义了一套dubbo的SPI机制,就是因为java中的SPI机制的一些缺点,不能满足dubbo的使用场景或者说效率没有那么高效
  • 所以dubbo自己封装了一套更强大的SPI机制,直接封装到了ExtensionLoader 类中,通过 ExtensionLoader,我们可以加载指定的实现类。Dubbo SPI 所需的配置文件需放置在 META-INF/dubbo 路径,并且可以直接给实现类进行一个命名,然后在使用的时候,直接通过@SPI(“名字”)去找到具体的实现类,比如:random(名字)=com.alibaba.dubbo.rpc.cluster.loadbalance.RandomLoadBalance(实现类) 

(2)dubbo中SPI机制的使用流程:

  1. 首先如果你想给一个接口需要用dubbo的SPI机制实现,那么首先你需要给这个接口上加上@SPI(“使用默认的实现类的名字”)这个注解

  2. 然后需要在你接口中的方法上加上一个@Adaptive注解,这个注解的核心功能就是:会为该方法生成对应的代码。方法内部会根据方法的参数,来决定使用哪个扩展,@Adaptive注解用在类上代表实现一个装饰类,类似于设计模式中的装饰模式

  3. 然后通过你在配置文件中配置好并实现你需要的实现类,然后供其进行调用完成,类似下面

    random=com.alibaba.dubbo.rpc.cluster.loadbalance.RandomLoadBalance roundrobin=com.alibaba.dubbo.rpc.cluster.loadbalance.RoundRobinLoadBalance leastactive=com.alibaba.dubbo.rpc.cluster.loadbalance.LeastActiveLoadBalance consistenthash=com.alibaba.dubbo.rpc.cluster.loadbalance.ConsistentHashLoadBalance

dubbo框架zookeeper注册中心挂了,调用服务会报错吗?

使用过dubbo框架的同学都知道,dubbo框架的服务调用是通过zookeeper注册中心找到服务地址后,进行调用。

如果中途zookeeper注册中心挂了,服务调用会报错吗?

可以的,启动dubbo时,消费者会从zk拉取注册的生产者的地址接口等数据,缓存在本地。每次调用时,按照本地存储的地址进行调用

注册中心对等集群,任意一台宕掉后,会自动切换到另一台

注册中心全部宕掉,服务提供者和消费者仍可以通过本地缓存通讯

服务提供者无状态,任一台 宕机后,不影响使用

服务提供者全部宕机,服务消费者会无法使用,并无限次重连等待服务者恢复

开发、测试环境可通过指定Url方式绕过注册中心直连指定的服务地址,避免注册中心中服务过多,启动建立连接时间过长,如

    <dubbo:reference id="providerService" interface="org.jstudioframework.dubbo.demo.service.ProviderService" url="dubbo://127.0.0.1:29014"/>

Dubbo 和 Spring Cloud 的关系?

Dubbo 是 SOA 时代的产物,它的关注点主要在于服务的调用,流
量分发、流量监控和熔断。而 Spring Cloud 诞生于微服务架构时
代,考虑的是微服务治理的方方面面,另外由于依托了 Spirng、
Spirng Boot 的优势之上,两个框架在开始目标就不一致,Dubbo
定位服务治理、Spirng Cloud 是一个生态。
 
最大的区别:Dubbo 底层是使用 Netty 这样的 NIO 框架,是基于
TCP 协议传输的,配合以 Hession 序列化完成 RPC 通信。
而 SpringCloud 是基于 Http 协议+Rest 接口调用远程过程的通信,
相对来说,Http 请求会有更大的报文,占的带宽也会更多。但是
REST 相比 RPC 更为灵活,服务提供方和调用方的依赖只依靠一纸契
约,不存在代码级别的强依赖。

ZooKeeper提供了什么(本质)?

1、 文件系统 2、 通知机制

 Zookeeper文件系统

 Zookeeper提供一个多层级的节点命名空间(节点称为znode)。与文件系统不同的是,这些节点 都可以设置 关联的数据 ,而文件系统中只有文件节点可以存放数据而目录节点不行。Zookeeper为了保证高吞吐和低延 迟,在内存中维护了这个树状的目录结构,这种特性使得Zookeeper 不能用于存放大量的数据 ,每个节点的存 放数据上限为 1M 。

3.volatile关键词的作用

被volatile修饰的变量,可以保证不同的线程都能取得最新状态值;volatile保证了可见性,避免线程在缓存中取旧值;

1. volatile 保证可见性

一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:

1)保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。

2)禁止进行指令重排序。

2. volatile 不能确保原子性

下面看一个例子:

public class Test {
    public volatile int inc = 0;
 
    public void increase() {
        inc++;
    }
 
    public static void main(String[] args) {
        final Test test = new Test();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        test.increase();
                };
            }.start();
        }
 
        while(Thread.activeCount()>1)  //保证前面的线程都执行完
            Thread.yield();
        System.out.println(test.inc);
    }

4.Spring中Bean的生命周期是怎样的?

crossoverjie.top/2018/03/21/…