Java集合常见面试题总结(下)

Map(重要)

HashMap和Hashtable的区别

线程是否安全:

HashMap是非线程安全的,Hashtable是线程安全的,因为Hashtable内部的方法基本都经过sychronized修饰。(如果你要保证线程安全的话就使用ConcurrentHashMap把!);

效率:因为线程安全的问题,HashMap要比Hashtable效率高一点。

另外,Hashtable基本被淘汰,不要在代码中使用它;

对Null key 和Null value的支持:HashMap可以存储null的key和value,但null作为键只能有一个,null作为值可以有多个;

Hashtable不允许有null键和null值,否则或抛出NullPointerException.

初始容量大小和每次扩充容量大小的不同:

1创建时如果不指定容量初始值,Hashtable默认的初始大小为11,之后每次扩充,容量变为原来的2n+1.

HashMap默认的初始化大小为16。之后每次扩充,容量变为原来的2倍。

创建时如果给定了容量初始值,那么Hashtable会直接使用你给定的大小,而HashMap会将其扩充为2的幂次方大小(HashMap中的tableSizeFor()方法保证,下面给出了源代码)。

也就是说HashMap总是使用2的幂作为哈希表的大小,后面会介绍到为什么是2的幂次方。

底层数据结构:

JDK1.8以后的HashMap在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)时,将链表转换为红黑树(将链表转换成红黑树前会判断,如果当前数组的长度小于64,那么会选择先进行数组扩容,而不是转换为红黑树),以减少搜索时间(后文中我会结合源码对这一过程进行分析)。

Hashtable没有这样的机制。

哈希函数的实现:HashMap对哈希值进行了高位和低位的混合扰动处理以减少冲突,而Hashtable直接使用键的hashCode()值。

HashMap中带有初始容量的构造函数

public HashMap(int initialCapacity,float loadFactor) {
    if (initialCapcity < 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.threshoud = tableSizeFor(initialCapacity);
}
public HashMap(int initialCapacity) {
    this(initialCapacity,DEFAULT_LOAD_FACTOR);    
}

下面这会方法保证了HashMap总是使用2的幂作为哈希表的大小

/**
* Returns a power of two size for the given target capacity.
*/
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;
}

HashMap和HashSet区别

如果你看过HashSet源码的话就应该知道:HashSet底层宿舍基于HashMap实现的。

(HashSet的源码非常非常少,因为除了clone(),writeObject(),readObject()是HashSet自己不得不实之外,其他方法都是直接调用HashMap中的方法)

ashMap 中的方法。

HashMapHashSet
实现了 Map 接口实现 Set 接口
存储键值对仅存储对象
调用 put()向 map 中添加元素调用 add()方法向 Set 中添加元素
HashMap 使用键(Key)计算 hashcodeHashSet 使用成员对象来计算 hashcode 值,对于两个对象来说 hashcode 可能相同,所以equals()方法用来判断对象的相等性

HashMap和TreeMap区别

 TreeMap和HashMap都继承自AbstractMap,但是需要注意的是TreeMap它还实现了NavigableMap接口和SortedMap接口

实现NavigableMap接口让TreeMap有了对集合内元素的搜索的能力。

NavigableMap接口提供了丰富的方法来探索和操作键值对:

1.定向搜索:ceilingEntry(),floorEntry(),higherEntry()和lowerEntry()等方法可以用于定位大于等于,小于等于,严格大于,严格小于给定键的最接近的键值对。

2.子集操作:subMap(),headMap()和tailMap()方法可以高效地创建原集合的子集视图,而无需复制整个集合。

3.逆序视图:descendingMap()方法返回一个逆序的NavigableMap视图,使得可以反向迭代整个TreeMap.

4.边界操作:firstEntry(),lastEntry(),pollFirstEntry()和pollLastEntry()等方法可以方便地访问和移除元素。

这些方法都是基于红黑树数据结构的属性实现的,红黑树保持平衡状态,从而保证了搜索操作的时间复杂度为O(log n),这让TreeMap成为了处理有序·1集合搜索问题的强大工具。

实现SortedMap接口让TreeMap有了对集合中的元素根据键排序的能力。

默认是按key的升序排序,不过我们也可以指定排序的比较器。

示例代码如下:

/**
* @author shuang.kou
* @createTime 2020年06月15日 17:02:00
* /
public class Person {
    private Integer age;

    public Person(Integer age) {
        this.age = age;
    }

    public Integer getAge() {
        return age;
    }

    public static void main(String[] args) {
        TreeMap<Person,String> treeMap = new TreeMap<>(new Comparator(Person)() {
            @Override
            public int compare(Person person1,Person person2) {
                int num = person1.getAge() - perons2.getAge();
                return Integer.compare(num,0);
            }
        });
        treeMap.put(new Person(3),"person1");
        treeMap.put(new Person(18),"person2");
        treeMap.put(new Person(35),"person3");
        treeMap.put(new Person(16),"person4");
        treeMap.entrySet().stream().forEach(personStringEntry -> {
            System.out.println(personStringEntry.getValue());
        });
    }
}

 输出:

person1
person4
person2
person3

可以看出,TreeMap中的元素已经是按照Person的age字段的升序来排列了。

上面,我们是通过传入匿名内部类的方式实现的,你可以将代码替换成Lambda表达式实现的方式:

TreeMap<Person,String> treeMap = new TreeMap<>((person1,perons2)-> {
    int num = person1.getAge() - person2.getAge();
    return Intefer.compare(num,0);
});

综上,相比于HashMap来说,TreeMap主要多了对集合中的元素根据键排序的能力以及对集合内元素的搜索的能力。

HashSet如何检查重复?
以下内容摘自我的Java启蒙书《Head first java》第二版:

当你把对劲加入HashSet时,HashSet会先计算对象的hashcode值来判断对象加入的位置,同时也会与其他加入的对象的hashcode值作比较,如果没有相符的hashcode,HashSet会假设对象没有重复出现。

但是如果发现有相同hashcode值的对象,这时会调用equals()方法来检查hashcode相等的对象是否真的相同。

如果两者相同,HashSet就不会让加入操作成功。

在JDK1.8中,HashSet的add()方法只是简单的调用了HashMap的put()方法,并且判断了一下返回值以确保是否有重复元素。

直接看一下HashSet中的源码:

// Returns:true if this set did not already contain the specified element
// 返回值:当set中没有包含add的元素时返回真
public boolean add(E e) {
    return map.put(e,PRESENT)==null;
}

而在HashMap的putVal()方法中也能看到如下说明:

// Returns:previous value,or null if none
// 返回值:如果插入位置没有元素返回null,否则返回上一个元素
final V putVal(int hash,K key,V value,boolean onlyIfAbsent,boolean evict) {
    ...
}

也就是说,在JDK1.8中,实际上无论HashSet中是否已经存在了某元素,HashSet都会直接插入,只是会在add()方法的返回值处告诉我们插入之前是否存在相同元素,

HasMap底层实现

JDK1.8之前HashMap底层是数组和链表结合在一起使用也就是链表散列,

HashMap通过key的hashcode经过扰动函数处理后得到hash值,然后通过(n-1)&hash判断当前元素存放的位置(这里的n指的是数组的长度),如果当前位置存在元素的话,就判断该元素与要存入的元素的hash值以及key是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。

HashMap中的扰动函数(hash方法)是用来优化哈希值的分布。

通过对原始的hashCode()进行额外处理,扰动函数可以减小由于糟糕的hashCode()实现导致的碰撞,从而提高时间的分布均匀性。

JDK1.8HashMap的hash方法源码:

JDK1.8的hash方法相比于JDK.17hash方法更加简化,但是原理不变。

static final int hash(Object key) {
    int h;
    // key.hashCode():返回散列值也就是hashcode
    // ^:按位异或
    // >>>:无符号右移,忽略符合位,空位都以0补齐
    return (key == null) ? 0 : (h = key.hashCode()) ^(h >>>16);
}

对比一下JDK1.7的HashMap的hash方法源码

static int hash(int h) {
    // 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的hash方法,JDK1.7的hash方法的性能会稍微差一点点,因为毕竟扰动了4次。

所谓“拉链法”就是:将链表和数组相结合。

也就是说创建一个链表数组,数组中每一格就是一个链表。

若遇到哈希冲突,则将冲突的值加到链表中即可

jdk1.8之前的内部结构-HashMap

JDK1.8之后

相比于之前的版本,JDK1.8之后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)(将链表转换成红黑树前会判断,如果当前数组的长度小于64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间

TreeMap,TreeSet以及JDK1.8之后的HashMap底层都用到了红黑树。

红黑树就是为了解决二叉查找树的缺陷,因为二叉查找树在某些情况下会退化成一个线性结构。

我们来结合源码分析一下HashMap链表到红黑树的转换。

1.putVal方法中执行链表转红黑树的判断逻辑

链表的长度大于8的时候,就执行treeifyBin(转换红黑数)的逻辑。

// 遍历链表
for (int binCount = 0;; ++binCount) {
    // 遍历到链表最后一个节点
    if ((e = p.next) == null) {
        p.next = newNode(hash,key,value,null);
        // 如果链表元素个数大于TREEIFY_THRESHOLD(8)
        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
            //红黑树转换(并不会直接转换成红黑树)
            treeifyBin(tab,hash);
        break;
    }
    if (e.hash == hash && ((k = e.key || (key != null && key.equals(k))))
        break;
    p = e;
}

2.treeifBin方法中判断是否真的转换为红黑树。

final void treeifBin(Node<K,V>[] tab,int hash) {
    int n,index;Node<K,V> e;
    // 判断当前数组的长度是否小于64
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        //如果当前数组的长度小于64,那么会选择先进行数组扩容
        resize();
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        // 否则才将列表转换成红黑树

        TreeNode<K,V> hd = null,t1 = null;
        do {
            TreeNode<K,V> p = replacementTreeNode(e,null);
            if (t1 == null)
                hd =p;
            else {
                p.prev = t1;
                t1.next = p;
            }
            t1 = p;
        } while ((e = e.next) != null);
        if ((tab[index] = hd) != null)
            hd.treeify(tab);
        }
}

将链表转换成红黑树前会判断,如果当前数组的长度小于64,那么会选择先进行数组扩容,而不是转换为红黑树。

HashMap的长度为什么是2的幂次方

为了让HashMap存取高效并减少碰撞,我们需要确保数据尽量均匀分布。

哈希值在Java中通常使用int表示,其范围是-2147483648 —— 2147483637前后加起来大概40亿的映射空间,只要核心函数映射得比较均匀松散,一般应用是很难出现碰撞的。

但是,问题是一个40亿长度的数组,内存是放不下的。

所以,这个散列值是不能直接拿来用的。

用之前还要先做对数组的长度取模运算,得到的余数才能用来要存放的位置也就是对应的数组下标。

这个算法应该如何设计呢?

我们首先可能会想到采用%取余的操作来实现。

但是,重点来了:“取余(%)操作中如果除数是2的幂次则等价于与其除数减一的与(&)操作(也就是说hash%length == hash &(length-1)的前提是length是2的n次方)”

并且,采用二进制位操作&相对于%能够提高运算效率。

除了上面所说的位运算比取余效率高之外,我觉得更重要的一个原因是长度是2的幂次方,可以让HashMap在扩容的时候更均匀。

例如:

length = 8 时,length -1 = 7的二进制位0111

length = 16时,length -1 = 15的二进制位1111

这时候原本存在HashMap中的元素计算新的数组位置时hash&(length-1),取决hash的第四个二进制位(从右数),会出现两种情况:

1.第四个二进制位为0,数组位置不变,也就是说当前元素在数组和旧数组的位置相同。

2.第四个二进制位为1,数组位置在新数值扩容之后的那一部分。

这里例举一个例子:

假设有一个元素的哈希值为10101100

旧数组元素位置计算:
hash        = 10101100
length - 1  = 00000111
&-------------------------
index       = 000000100(4)

新数组元素位置计算:
hash        = 10101100
length - 1  = 00001111
&--------------------------
index       = 00001100 (12)

看第四位(从右数)
1.高位为0:位置不变
2.高位为1:移动到新位置(原索引位置+原容量)

注意:这里例举的场景看的是第四个二进制位,更准确点来说看的是高位(从右数),例如length = 32 时,length  - 1 = 31,二进制位11111,这里看的就是第五个二进制位。

也就是说扩容之后,在旧数组元素hash值比较均匀(至于hash值均不均匀,取决于前面讲的对象的hashcode()方法和扰动函数)的情况下,新数组元素也会被分配的比较均匀,最好的情况是会有一半的新数组的前半部分,一半在新数组后半部分。

这样也使得扩容机制变得简单和高效,扩容后只需要检查哈希值高位的变化来决定元素的新位置,要么位置不变(高位为0),要么就是移动到新位置(高位为1,原索引位置+原容量)

最后,简单总结一下HashMap的长度是2的幂次方的原因:

1.位运算效率更高:位运算(&)比取余运算(%)更高效。

当长度为2的幂次方时,hash%length等价于hash&(length - 1)

2.可以更好地保证哈希值的均匀分布:扩容之后,在旧数组元素hash值比较均匀的情况下,新数组元素也会被分配的比较均匀,最好的情况是会有一半在新数组的前半部分,一半在新数组后半部分。

3、扩容机制变得简单和高效:扩容后只需要检查哈希值高位的变化来决定元素的新位置,要么位置不变(高位为0),要么就是移动到新位置(高位为1,原索引位置+原容器)

HashMap多线程操作导致死循环问题

JDK1.7及之前版本的HashMap在多线程导致死循环问题

JDK1.7及之前版本的HashMap在多线程环境下扩容操作可能存在死循环问题,这是由于当一个桶位中有多个元素需要进行扩容时,多个线程同时对链表进行操作,头插法可能会导致链表中的节点指向错误的位置,从而形成一个环形链表,进而使得查询元素的操作陷入死循环无法结束。

为了解决这个问题,JDK1.8版本的HashMap采用尾插法而不是头插法来避免链表倒置,使得插入的节点永远都是放在链表的末尾,避免了链表中的环形结果。

但是还是不建议在多线程下使用HashMap,因为多线程下使用HashMap还是会存在数据覆盖的问题。

并发环境下,推荐使用ConcurrentHashMap.

一般面试中这样介绍就差不多,不需要记住各种细节,个人决定也没必要记。

如果想要详细了解HashMap扩容导致死循环问题,可以看看 耗子数的这篇文章:Java HashMap的死循环。

HashMap为什么线程不安全?

JDK1.7及之前版本,在多线程环境下,HashMap扩容时会造成死循环和数据丢失的问题。

数据丢失这个在JDK1.7和JDK1.8中都存在,这里以JDK1.8为例进行介绍。

JDK1.8后,在HashMap中,多个键值对可能会被分配到同一个桶(bucket),并以链表或红黑树的形式存储。

多个线程对HashMap的put操作会导致线程不安全,具体来说会有数据覆盖的风险。

举个例子:

两个线程1,2同时进行put操作,并且发生了哈希冲突(hash函数计算出的插入下标是相同的)

不同的线程可能在不同的时间片获得CPU执行的机会,当前线程1执行完哈希冲突判断后,由于时间片耗尽挂起。

线程2先完成了插入操作。

随后,线程1获得时间片,由于之前已经进行过hash碰撞的判断,所有此时会直接进行插入,这就导致线程2插入的数据被线程1覆盖了。

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) {
    //...
    // 判断是否出现hash碰撞
    // (n - 1) & hash 确定元素存放在那个桶中,桶为空,新生成结点放入桶中(此时,这个结点是放在数组中)
    if ((p = tab[i = (n-1)&hash]) == null)
        tab[i] = newNode(hash,key,value,null);
    //桶中已经存在元素(处理hash冲突)
    else{
    // ...
}

还有一种情况是这两个线程同时put操作导致size的值不正确,进而导致数据覆盖的问题:

1.线程1执行if(++size >threshould)判断时,假设获得size的值为10,由于时间片耗尽挂起。

2.线程2也执行if(++size >threshould)判断,获得size的值也为10,并将元素插入到该桶位中1,并将size的值更新为11

3.随后,线程1获得时间片,它也将元素放入桶位中,并将size的值更新为11

4.线程1,2都执行了一次put操作,但是size的值只增加了1,也就导致实际上只有一个元素被添加到了HashMap中。

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) {
    //...
    // 实际大小大于阈值则扩容
    if (++sizr > threshold)
        resize();
    // 插入后回调
    afterNodeInsertion(evict);
    return null;
}

HashMap常见的遍历方式?

HashMap的7中遍历方式与性能分析!

修饰(参见:isuue#1411):

这篇文章对与parallelStream遍历方式的性能分析有误,先说结论:存在阻塞时parallelStream性能最高,非阻塞时parallelStream性能最低。

当遍历不存在阻塞时,parallelStream的性能是最低的:

Benchmark               Mode  Cnt     Score      Error  Units
Test.entrySet           avgt    5   288.651 ±   10.536  ns/op
Test.keySet             avgt    5   584.594 ±   21.431  ns/op
Test.lambda             avgt    5   221.791 ±   10.198  ns/op
Test.parallelStream     avgt    5  6919.163 ± 1116.139  ns/op

加入阻塞代码Thread.sleep(10)后,parallelStream的性能才是最高的

Benchmark               Mode  Cnt           Score          Error  Units
Test.entrySet           avgt    5  1554828440.000 ± 23657748.653  ns/op
Test.keySet             avgt    5  1550612500.000 ±  6474562.858  ns/op
Test.lambda             avgt    5  1551065180.000 ± 19164407.426  ns/op
Test.parallelStream     avgt    5   186345456.667 ±  3210435.590  ns/op

ConcurrentHashMap和hashtable的区别

ConcurrentHashMap和Hashtable的区别主要体现在实现线程安全的方式上不同。

底层数据结构:JDK1.7的ConcurrentHashMap底层采用分段的数组+链表实现,JDK1.8采用的数据结构跟HashMap1.8的结构一样,数组+链表/红黑二叉树。

Hashtable和JDK1.8之前的HashMap的底层数据结构类似都是采用数组+链表的形式,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的;

实现线程安全的方式(重要)

在JDK1.7的时候,ConcurrentHashMap对整个桶数组进行了分割分段(Segment,分段锁),每一把锁只锁容器其中一部分数据(下面有示意图),多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。

到了JDK1.8的时候,ConcurrentHashMap已经摈弃了Segment的概率,而是直接用Node数组+链表+红黑树的数据结构来实现,并发控制使用synchronized和CAS来操作。

(JDK1.6以后synchronized锁做了很多优化)整个看起来就像是优化过且线程安全的HashMap,虽然在JDK1.8中还能看到Segment的数据结构,但是已经简化了属性,只是为了兼容旧版本;

Hashtable(同一把锁):使用synchronized来保证线程安全,效率非常低下。

当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用put添加元素,另一个线程不能使用put添加元素,也不能使用get,竞争会越来越激烈效率越低。

下面,我们再来看看两者底层数据结构的对比图。

 ConcurrentHashMap是由Segment数组结构和HashEntry数组结构组成。

Segment数组中的每个元素包含一个HashEntry数组,每个HashEntry数组属于链表结构。

JDK1.8的ConcurrentHashMap:

JDK1.8的ConcurrentHashMap不再是Segment数组+HashEntry数组+链表,而是Node数组+链表/红黑树。

不过,Node只能用于链表的情况,红黑树的情况需要使用TreeNode.

当冲突链表达到一定长度时,链表会转换成红黑树。

TreeNode是存储红黑树节点,被TreeBin包装。

TreeBin通过root属性维护红黑树的根结点,因为红黑树在旋转的时候,根结点可能会被它原来的子节点替换掉,在这个时间点,如果有其他线程要写这棵红黑树就会发生线程不安全问题,所以在ConcurrentHashMap中TreeBin通过waiter属性维护当前使用这棵红黑树的线程,来防止其他线程的进入。

static final class TreeBin<K,V> extends Node<K,V> {
    TreeNode<K,V> root;
    volatile TreeNode<K,V> first;
    volatile Thread waiter;
    volatile int lockState;
    // values for lockState;
    static final int WRITER = 1; // set while holding write lock
    static final int WAITER = 2; // set when waiting for write lock
    static final int READER = 4; // increment value for setting read lock
...
}

ConcurrentHashMap线程安全的具体实现方式/底层具体实现

JDK1.8之前

首先将数据分为一段一段(这个“段”就是segment)的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。

ConcurentHashMap是由Segment数组结构和HashEntry数组结构组成。

Segment继承了ReentractLcok,所以Segment是一直可重入锁,扮演锁的角色。

HashEntry用于存储键值对数据

static class Segment<K,V> extends ReentrantLock implements Serializable {
}

 一个ConcurrentHashMap里包含一个Segment数组,,Segment的个数一旦初始化就不能改变。

Segment数组的大小默认是16,也就是说默认可以同时支持16个线程并发写。

Segment的结构和HashMap类似,是一种数组和链表结构,一个Segment包含一个HashEntry数组,每个HashEntry是一个链表结构的元素,每个Segment守护着一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时,必须首先获得对应的Segment的锁。

也就是说,对同一Segment的并发写入会被阻塞,不同Segment的写入是可以并发执行的。

JDK1.8之后

Java8几乎完全重写了ConcurrentHashMap,代码量从原来Java7中的1000多行,变成了现在的6000多行。

ConcurrentHashMap取消了Segment分段锁,采用Node+CAS+synchronized来保证并发安全。

数据结构跟HashMap1.8的结构类型,数组+链表/红黑二叉树。

Java8在链表长度超过一定阈值(8)时将链表(寻址时间复杂度为O(N))转换为红黑树(寻址时间复杂度为O(log(N))。

Java8中,锁粒度更细,synchronized只锁定当前链表或红黑树二叉树的首节点,这样只要hash不冲突,就不会产生并发,就不会影响其他Node的读写,效率大幅提升。

JDK1.7和JDK1.8的ConcurrentHashMap实现有什么不同?

线程安全实现方式:JDK1.7采用Segment分段锁来保证安全,Segment是继承自ReentrantLock.

JDK1.8放弃了Segment分段锁的设计,采用Node+CAS+synchronized保证线程安全,锁粒度更细,synchronized只锁定当前链表或红黑二叉树的首节点。

Hash碰撞解决方法:JDK1.7采用拉链法,JDK1.8采用拉链法结合红黑树(链表长度超过一定阈值时,将链表转换为红黑树)。

并发度:JDK1.7最大并发度是Segment的格式,默认是16.

JDK1.8最大并发度是Node数组的大小,并发度更大。

ConcurrentHashMap为什么key和value不能为null?

ConcurrentHashMap的key和value不能为null主要是为了避免二义性。

null是一个特殊的值,表示没有对象或没有引用。

如果你用null作为键,那么你就无法区分这个键是否存在于ConcurrentHashMap中,还是根本没有这个键。

同样,如果你用null作为值,那么你就无法区分这个值是否是真正存储在ConcurentHashMap中的,还是因为找不到对应的键而返回的。

拿get方法取值来说,返回的结果为null存在两种情况:

值没有在集合中;

值本身就是null.

这也就是二义性的由来。

具体可以参考ConcurrentHashMap源码分析

多线程环境下,存在一个线程操作该ConcurrentHashMap时,其他的线程将该ConcurrentHashMap修改的情况,所以无法通过containsKey(key)来判断否存在这个键值对,也就没办法解决二义性问题了。

与此形成对比的是,HashMap可以存储null的key和value,但null作为键只能有一个,null作为1值可以有多个。

如果传入null作为参数,就会返回hash值为0的位置的值。

单线程环境下,不存在一个线程操作该HashMap时,其他的将该HashMap修改的情况,所以可以通过contains(key)来做判断是否存在这个键值对,从而做相应的处理,也就不存在二义性问题。

也就是说,多线程下无法正确判定键值对是否存在(存在其他线程修改的情况),单线程是可以的(不存在其他线程修改的情况)。

如果你确实需要在ConcurrentHashMap中使用null的话,可以使用一个特殊的静态空对象来代替。

public static final Object NULL = new Object();

最后,再分享一下ConcurrentHashMap作者本人(Doug Lea)对于这个问题的回答:

The main reason that nulls aren's allowed in ConcurrentMaps (ConcurrentHashMaps,ConcurrentSkipListMaps) is that ambiguities that may be just barely tolerable in non-concurrent maps can't be accommodated.

The main one is that if map.get(key) returns null,you can't detect whether the key explicity maps to null vs the key isn't mapped.

In a non-concurrent map,you can check this via map.contains(key),but in a concurrent one,the map might have changed between calls.

翻译过来之后的,大致意思还是单线程下可以容忍歧义,而多线程下无法容忍。

ConcurrentHashMap能保证复合操作的原子性吗?

ConcurrentHashMap是线程安全的,意味着它可以保证多个线程同时对它进行读写操作时,不会出现数据不一致的情况,也不会导致JDK1.7及之前版本的HashMap多线程操作导致死循环问题。

但是,这并不意味着它可以保证所有的复合操作都是原子性的,一定不要搞混了!

复合操作是指由多个基本操作(如put,get,remove,containsKey等)组成的操作,例如先判断某个键是否存在containsKey(key),然后根据结果进行插入或更新put(key,value).

这种操作在执行过程中可能会被其他线程打断,导致结果不符合预期。

例如,有两个线程A和B同时对ConcurrentHashMap进行复合操作,如下:

// 线程 A
if (!map.containsKey(key)) {
map.put(key,value);
}
// 线程 B
if (!map.containsKey(key)) {
map.put(key.anotherValue);
}

如果线程A和B的执行顺序是这样:

1.线程A判断map中不存在key

2.线程B判断map中不存在key

3.线程B将(key,anotherValue)插入map

4.线程A将(key,value)插入map

那么最终的结果是(key,value),而不是预期的(key,anotherValue).

这就是复合操作的非原子性导致的问题。

那如何保证ConcurrentHashMap复合操作的原子性呢?

ConcurrentHashMap提供了一些原子性的复合操作,如putIfAbsent,compute,computeIfAbsent,computerIfPresent,merge等。

这些方法都可以接受一个函数作为参数,根据给定的key和value来计算一个新的value,并且将其更新到map中。

上面的代码可以改写为:

// 线程A
map.putIfAbsent(key,value);
// 线程B
map.putIfAbsent(key,anotherValue);

或者:

// 线程A
map.computeIfAbsent(key,k -> value);
// 线程B
map.computeIfAbsent(key,k -> anotherValue);

很多同学可能会说了,这种情况也能加锁同步呀!

确实可以,但不建议使用加锁的同步机制,违背了使用ConcurrentHashMap的初衷。

在使用ConcurrentHashMap的时候,尽量使用这些原子性的复合操作方法来保证原子性。

Collections工具类(不重要)

Collections工具类常用方法:

排序

查找,替换操作

同步控制(不推荐,需要线程安全的集合类型时请考虑使用JUC包下的并发集合)

排序操作

void reverse(List list)//反转
void  shuffle(List list)//随机排序
void sort(List list)//按自然排序的升序排序
void sort(List list,Comparator c)//定制排序,由Comparator控制排序逻辑
void swap(List list,int i,int j)//交换两个索引位置的元素
void rotate(List list,int distance)//旋转。当distance为正数时,将list后distance个元素整体移到前面。当distance为负数时,将list的前distance个元素整体移到后面。

查找,替换操作

int binarySearch(List list,Object key)//对List进行二分查找,返回索引,注意List必须是有序的
int max(Collection coll)//根据元素的自然顺序,返回最大的元素。类比int min(Collection coll)
int max(Collection coll,Comparator x)//根据定制排序,返回最大元素,排序规则由Comparatator类控制。
void fill(List list,Object obj)//用指定的元素代替指定list中的所有元素。
int frequency(Collection c,Object o)//统计元素出现次数
int indexOfSubList(List list,List target)//统计target在list中第一次出现的索引,找不到则返回-1,类比int lastIndexOfSubList(List source,list target)
boolean replaceAll(List list,Object oldVal,Object newVal)//用新元素替换旧元素

同步控制

Collections提供了多个synchronizedXxx()方法,该方法可以将指定集合包装成线程同步的集合,从而解决多线程并发访问集合时的线程安全问题。

我们知道HashSet,TreeSet,ArrayList,Linked

ist,HashMap,TreeMap都是线程不安全的。

Collections提供多个静态方法可以把他们包装成线程同步的集合。

最好不要用下面这些方法,效率非常低,需要线程安全的集合类型时请考虑使用JUC包下的并发集合。

方法如下:

synchronizedCollection(Collection<T> c)//返回指定collection支持的同步(线程安全的)Collection。
synchronizedList(List<T> list)返回指定列表支持的同步(线程安全的)List
synchronizedMap(Map<K,V> m)//返回由指定映射支持的同步(线程安全的)Map
synchronizedSet(Set<T> s)//返回指定set支持的同步(线程安全的)set

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值