并发下的集合不安全问题

序言

由于最近项目上遇到了高并发问题,而自己对高并发,多线程这里的知识点相对薄弱,尤其是基础,所以想系统的学习一下,以后可能会出一系列的JUC文章及总结 ,同时也为企业级的高并发项目做好准备。

本文是JUC文章的第三篇,如想看以往关于JUC文章,请点击JUC系列总结

此系列文章的总结思路大致分为三部分:

  1. 理论(概念);
  2. 实践(代码证明);
  3. 总结(心得及适用场景)。

在这里提前说也是为了防止大家看着看着就迷路了。

备注:本文的阅读需要Volatile、CAS、Synchronized以及集合原理部分知识阅读最佳。

集合不安全问题大纲

集合不安全问题.png


ArrayList

我们都知道ArrayList是线程不安全,但是具体的不安全体现在哪?或者说你用代码去证明他的不安全性以及他的解决方案。

不安全原因

ArrayList是非线性安全,具体现在多线程环境下,对集合的操作:

  1. 一方线程在遍历列表,另一方线程在修改列表时,会报ConcurrentModificationException。
  2. 多线程插入操作,即add方法,由于没有同步操作,容易丢失数据,同时也可能出现索引越界异常(ArrayIndexOfBoundsException)。
 public boolean add(E e) {
	ensureCapacity(size + 1);  // Increments modCount!!
	elementData[size++] = e;//使用了size++操作,会产生多线程数据丢失问题。
	return true;
    }

而对于多线程操作问题,其最本质的问题就是通过锁来解决。大致分为3种解决方案:

  1. 使用VectorArrayList所有方法加synchronized,锁作用范围为方法,比较重)。
  2. 使用Collections.synchronizedList()转换成线程安全类(锁作用范围为代码块)。
  3. 使用java.concurrent.CopyOnWriteArrayList(分场景使用)。

​ 参考连接:为什么说ArrayList是线程不安全的?

其实就两点:

  1. 在判断是否需要扩容的时候,多线程环境下会对其造成影响导致误判没有扩容,接而导致索引越界;
  2. 在进行下一步elementData[s]=e,s=s+1的时候,如果多个线程在elementData[s]=e造成值覆盖,就会造成数据丢失问题,也是为什么集合添加值得时候出现null;

代码证明不安全问题

并发修改异常证明

public class ArrayListNoSafe {

    public static void main(String[] args) {
        ArrayList<String> list = new ArrayList<>();
        for (int i = 0; i < 8; i++) {
            new Thread(() ->{
                list.add(UUID.randomUUID().toString().substring(0,8));
                System.out.println(Thread.currentThread().getName() + "\t" + list);
            },String.valueOf(i)).start();
        }
    }
}

出现了边遍历,边修改的情况,结果就可能如下(抛出ConcurrentModificationException异常):

1	[3f790bed, b0566629]
7	[3f790bed, b0566629, 74fa619f, 1d4d9f94, d125b6c6, 0c2dee3f, ae9c6ce2, edb884c7]
4	[3f790bed, b0566629, 74fa619f, 1d4d9f94, d125b6c6, 0c2dee3f, ae9c6ce2]
5	[3f790bed, b0566629, 74fa619f, 1d4d9f94, d125b6c6, 0c2dee3f]
2	[3f790bed, b0566629, 74fa619f, 1d4d9f94, d125b6c6]
6	[3f790bed, b0566629, 74fa619f]
3	[3f790bed, b0566629]
Exception in thread "0" java.util.ConcurrentModificationException
	...省略

多线程环境下的添加操作,出现数据遗漏(其实最本质的问题就是size++)

public static void main(String[] args) {
        ArrayList<String> list = new ArrayList<>();
        for (int i = 0; i < 10000; i++) {
            new Thread(() ->{
                list.add(UUID.randomUUID().toString().substring(0,8));
            },String.valueOf(i)).start();
        }
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.err.println("=====长度:"+list.size());
    }

输出结果:

=====长度:9997

其实不止会出现数据遗漏情况,有时候还会出现索引越界情况 ,原因是因为:

假如当前集合里面有9个元素,线程A,B经过add方法之后会出现11个元素,list在第10个元素会发生扩容。

  1. 线程A,B同时进入add方法,在判断是否要扩容时候,由于线程A,B是同时进入,导致误判没有扩容(以为只添加一个元素,此时size=9),此时假如线程A被挂起;
  2. 线程B进入add方法,并完成了整个流程,但由于数组未扩容,但是索引已+1(size=10),线程A被唤醒,执行elementData[s] = e,s代表下表,集合的下表默认减一,但现在已经是10了,所以会抛出索引越界异常;

输出结果:

Exception in thread "4012" java.lang.ArrayIndexOutOfBoundsException: 4164
	at java.util.ArrayList.add(ArrayList.java:463)
	at com.company.thread.collection.ArrayListNoSafe.lambda$main$0(ArrayListNoSafe.java:31)
	at java.lang.Thread.run(Thread.java:748)
=====长度:9992

解决方案1 Vector

Vector之所以可以保证线程安全,是因为它对所有操作都加上了synchronized关键字(注意:它锁定的范围时方法级别的),将整个方法都锁住。但它的本质还是ArrayList,就是简单粗暴的加上了synchronized,这种方式严重影响效率,而且在拓展性方面也不如Collections.SynchronizedList,仅作用于ArrayList。因此,不推荐使用Vector

Stackoverflow当中有这样的描述:Why is Java Vector class considered obsolete or deprecated?

但是作为ArrayList不安全的解决方式中,我们还是有必要说一下。代码也很简单,我们在这就不演示了。

解决方案2 Collections.SynchronizedList

与Vector相同的是它也加了synchronized关键字,但是最大不同的是它的synchronized锁定的范围是代码块,锁定的是构造函数传进来的list对象。(不仅限于ArrayList)

我们来翻阅一下源码:

publicstatic <T> List<T> synchronizedList(List<T> list) {
    return (list instanceof RandomAccess ?
            //ArrayList使用了SynchronizedRandomAccessList类
            new SynchronizedRandomAccessList<>(list) :
            new SynchronizedList<>(list));
}

通过源码可知,它其实就是通过 synchronizedList 静态方法将一个非线程安全的List(并不仅限ArrayList)包装为线程安全的List。

SynchronizedList方法如下:

//SynchronizedRandomAccessList继承自SynchronizedList
static class SynchronizedRandomAccessList<E> extends SynchronizedList<E> implements RandomAccess {
}

//SynchronizedList对代码块进行了synchronized修饰来实现线程安全性
static class SynchronizedList<E> extends SynchronizedCollection<E> implements List<E> {
    final List<E> list;
    SynchronizedList(List<E> list) {
        super(list);
        this.list = list;
    }
    SynchronizedList(List<E> list, Object mutex) {
        super(list, mutex);
        this.list = list;
    }
    public E get(int index) {
    	synchronized (mutex) {return list.get(index);}
    }
    public E set(int index, E element) {
        synchronized (mutex) {return list.set(index, element);}
    }
    public void add(int index, E element) {
        synchronized (mutex) {list.add(index, element);}
    }
    public E remove(int index) {
        synchronized (mutex) {return list.remove(index);}
    }   
    
    //迭代操作并未加锁,所以需要手动同步
    public ListIterator<E> listIterator() {
            return list.listIterator(); 
    }
}

Collections.synchronizedList生成了特定同步的SynchronizedCollection,生成的集合每个同步操作都是持有mutex这个锁,而这个mutex即构造函数传入的list,所以再进行操作时就是线程安全的集合了。

注意:这里需要注意一个地方:

  1. 迭代操作必须加锁,可以使用synchronized关键字修饰;
  2. synchronized持有的监视器对象必须是synchronized (list),即包装后的list,使用其他对象如synchronized (new Object())会使add,remove等方法与迭代方法使用的锁不一致,无法实现完全的线程安全性。

解决ArrayList不安全的代码:

	public static void main(String[] args) {
        collectionsSynchronizedListTest();
	}
	//collection.synchronizedList安全性测试
	private static void collectionsSynchronizedListTest() {
        List<String> list = Collections.synchronizedList(new ArrayList<String>());
        for (int i = 0; i < 80; i++) {
            new Thread(() ->{
                list.add(UUID.randomUUID().toString().substring(0,8));
                System.out.println(Thread.currentThread().getName()+"\t"+list);
            },String.valueOf(i)).start();
        }
        try {
            TimeUnit.SECONDS.sleep(2);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("list的长度====================="+list.size());
        collectionsSynchronizedListIteratorTest(list);
    }
	//collections.synchronizedList的遍历
    private static void collectionsSynchronizedListIteratorTest(List list) {
        synchronized (list){
            Iterator iterator = list.iterator();
            while (iterator.hasNext()){
                System.err.print(iterator.next() + ",");
            }
        }
    }

在这里提醒一下,增强for循坏其实底层也是调用的迭代器…

解决方案3 copyOnWriteArrayList

顾名思义,CopyOnWrite~ArrayList,写时复制,读写分离。即在进行写操作(add,remove,set等)时会进行Copy操作,可以推测出在进行写操作时CopyOnWriteArrayList性能应该不会很高,从而进一步可以推出它是适合在读多写少的情况下使用。

再者,我们可以发现这个类的所在包为 java.util.concurrent,可想而知,这个类是为并发而设计的.

为了防止看源码看的迷路,在这里先提前总结一下其原理

通过写时复制来实现读写分离。比如其add()方法,就是先复制一个新数组,长度为原数组长度+1,然后将新数组最后一个元素设为添加的元素。而读操作(读到的是修改前的数组)并没有对数组修改,不会产生线程安全问题。但同时也会带来一个新的问题,就是数据的一致性

线程1读取集合里面的数据,然后被挂起,线程2、线程3、线程4四个线程都修改/删除了CopyOnWriteArrayList里面的数据,操作完成后此时线程1被唤醒(由于太快了,volatile还未及时刷新主内存的值),线程1拿到的还是最老的那个Object[] array,此时如果 get(i)获取指定位置时,可能会造成索引越界异常。所以线程1读取的内容未必准确。

所以结论为:在不要求数据实时一致性的情况下,读不加锁,写加锁,使用copyOnWriteArrayList以提高性能。

接下来,我们来看一下它的结构:

public class CopyOnWriteArrayList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
    //lock锁
	final transient ReentrantLock lock = new ReentrantLock();
	//volatile保证可见性,一旦有线程修改,即可见。
    private transient volatile Object[] array;

    final Object[] getArray() {
        return array;
    }
	
    final void setArray(Object[] a) {
        array = a;
    }
	//构造方法,赋予初始值
    public CopyOnWriteArrayList() {
        setArray(new Object[0]);
    }
}

可以看到CopyOnWriteArrayList底层跟ArrayList一样,实现同为Object[] array数组。

添加操作

public boolean add(E e) {
        final ReentrantLock lock = this.lock;
        lock.lock();
        try {
            Object[] elements = getArray();
            int len = elements.length;
            //在原先数组基础之上新建长度+1的数组,并将原先数组当中的内容拷贝到新数组当中。
            Object[] newElements = Arrays.copyOf(elements, len + 1);
            //给最后一个元素设值
            newElements[len] = e;
            //对新数组进行赋值
            setArray(newElements);
            return true;
        } finally {
            lock.unlock();
        }
    }

可以看到每次添加元素时都会进行Arrays.copyOf操作,代价非常昂贵。

读操作

 public E get(int index) {
        return (E)(getArray()[index]);
    }

读的时候是不需要加锁的,直接获取。删除和增加是需要加锁的。

拓展

Vector与SynchronizedList的区别
  1. SynchronizedList它是对list集合的包装类,所以在扩容上是List的扩容机制,即1.5倍,而vector扩容为原来的2倍;
  2. SynchronizedList不止作用于ArrayList,它的作用域在整个List集合类。而vector相当于是线程安全下的ArrayList(因为其底层是object数组);
  3. SynchronizedList的遍历并没有加锁,所以遍历时需要同步处理;
  4. SynchronizedList的锁作用域为同步代码块,vector的锁作用域是整个方法。
Vector、SynchronizedList和copyOnWriteArrayList的效率对比

代补充

Vector、SynchronizedList和copyOnWriteArrayList的使用场景
  1. vector在所有的方法添加了synchronized关键字,并发性能差,不推荐使用;
  2. CopyOnWriteArrayList写时复制,读时没有锁,会出现数据性一致问题,所以在不要求数据实时一致性的情况下,使用copyOnWriteArrayList以提高性能;
  3. Collections.synchronizedList在写多的情况下使用,但需要注意迭代操作未加锁(其实还有一种场景,List的其他子类需要线程安全的条件下)。

HashSet

其实熟读过源码的同学都知道,HashSet底层是由HashMap实现 ,值存放于HashMap的key上 ,HashMap的value统一为PRESENT

而对于它不安全问题的原因我们会在HashMap处做详细的解释。

解决不安全问题可以通过Collections.synchronizedSet去解决,在这里,其原理跟上面的差不多,在这里我们就不做详述了。

HashMap

不安全原因

我们先说结论(以防在后面的介绍中迷失自我~~~)

这里需要分以下JDK版本:

JDK1.7版本其中有两点:

  1. 多线程环境操作下,在resize扩容的过程中,由于采用的是头插法,所以会导致环形链表的产生;
  2. get方法的不可见性;(即上一秒put完值,下一秒get值,所get到的值不是最新值。)
  3. put方法值如果index一致,多线程环境下可能会造成数据丢失,导致后一个插入的覆盖前一个的值。

JDK1.8版本:

​ 1. 由于1.8版本扩容机制也发生改变,所以环形链表问题出现的概率大幅度降低,由于有红黑树的引入,提高性能,将头插法改为尾插;
2. 最主要的就体现在get/put方法这里。

接下来我们来详细探讨一下其多线程环境下的操作的操作细节,主要从以下思路讲解:

  1. 什么是头插法,什么尾插法;
  2. 为什么会形成环形链表;
  3. 数据的不一致性。

头插法与尾插法

头插法

20180926181043191.png

如上图,这些过程可总结为两句话:

  1. 将头指针指向下一结点的地址赋给新增结点的next;
  2. 再将新增节点的地址赋值给头指针的下一个结点。
	node->next = head->next;
	head->next = node; 

通俗一点的白话就是说新来的值会取代原有的值,原有的值就顺推到链表的下一个节点。

尾插法

20180926194847575.png

同样的,总结为:

从新增第二个结点开始,尾指针总指向下一个节点next,最后一结点指向null;

白话就是说新来的值会顺序的添加到链表中。

为什么会形成环形链表(JDK1.7版本)

首先我们先来了解以下hashmap的扩容机制(resize过程):

分为两步:

  1. 创建一个新的Entry空数组,长度是原来的两倍。(因为hashmap的扩容机制是2^n);

  2. ReHash:由于数组长度改变,Hash的规则也随之改变(公式为hash&length-1),相当于重新将数据插入一遍。

    其实我所理解的ReHash,其实在另一方面是为了减少hash冲突(减少链表长度),因为rehash之后,每个桶上的节点数一定小于等于原来桶上的节点数。

然而其实问题也是出现在这个Resize,举个例子把:

假设我们现在往一个容量大小为2的put两个值,负载因子是0.75,阀值:2*0.75 = 1,所以我们在put第二个的时候就会进行resize。

然后我们现在用不同线程插入A,B,C,在未进行resize之前,我们看到的可能是这个样子的:

链表的指向A->B->C

A的下一个指针是指向B的

640.webp

因为resize的赋值方式,也就是使用了单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置,在旧数组中同一条Entry链上的元素,通过重新计算索引位置后,有可能被放到了新数组的不同位置上。

就可能出现下面的情况,大家发现问题没有?

B的下一个指针指向了A

640-1591520413511.webp

一旦几个线程都调整完成,就可能出现环形链表

640-1591520477693.webp

如果这个时候去取值,悲剧就出现了——Infinite Loop。

而JDK1.8版本在扩容时,如果重新hash的运算位为0,在新数组保持原位置,如果为1,则在新数组的位置为原始位置+扩容前的旧容量,这样做会保持链表元素原本的顺序不变,会大幅度降低链表成环的问题。

其中,在1.8版本还有一个改变,那就是将头插法改为尾插法,其实按照我的理解,是因为红黑树的引入,如果头插的话,你还得再去找到后一个进来的数据,这样不就造成了性能的浪费么。

其实准确的说对于使用这个尾插法我还是抱有疑问的,真的是是为了效率???)
如您有看法,请留言。

解决方案1 collections.SynchronizedMap

原理与上述Collections.SynchronizedList相同,此处略过;

解决方案2 HashTable

其实感觉跟Vector有点类似,直接在方法上添加了Synchronized关键字,锁住了整个方法,并发度很低。

[图片来源]:https://cloud.tencent.com/developer/article/1447127

cq1m9nzc42.png

解决方案3 ConcurrentHashMap

首先,在说concurrentHashMap之前,我们首先需要了解到它JDK1.7版本跟1.8版本还是有很大区别的。

JDK1.7:

采用分段锁segment实现,通过继承ReentrantLock做同步处理,并在HashEntry部分元素结点处添加volatile关键字。底层为数组+单链表的结构。

JDK1.8:

去除了segement机制,改为了CAS与synchronized结合的同步方式,并在node部分元素结点处添加volatile关键字,保证了get方法时的可见性,(因为get方法没有添加锁)。底层为数组+单链表+红黑树的结构。

ConcurrentHashMap原理简述

在这里,我们不做大篇幅的原理介绍,如您对源码感兴趣,可自行查阅相关文章。

JDK1.7:

[图片来源]:https://cloud.tencent.com/developer/article/1447127

0jmcnifo7n.png

对于1.7版本的ConcurrentHashMap来讲,它的并发度为16,因为它最多只允许创建16个segment。

put操作如下

首先它会先定位到具体的segment,然后进行添加元素操作

public V put(K key, V value) {
        Segment<K,V> s;
        //concurrentHashMap不允许key/value为空
        if (value == null)
            throw new NullPointerException();
        //hash函数对key的hashCode重新散列,避免差劲的不合理的hashcode,保证散列均匀
        int hash = hash(key);
        //定位segment
        int j = (hash >>> segmentShift) & segmentMask;
        if ((s = (Segment<K,V>)UNSAFE.getObject          
             (segments, (j << SSHIFT) + SBASE)) == null) 
            s = ensureSegment(j);
        return s.put(key, hash, value, false);
    }

接下来时segment内部的put方法:

final V put(K key, int hash, V value, boolean onlyIfAbsent) {
            HashEntry<K,V> node = tryLock() ? null :
                scanAndLockForPut(key, hash, value);//此处是一个自旋操作,尝试获取锁,其尝试次数由MAX_SCAN_RETRIES 控制,如果超过该值,则改为阻塞获取。
            V oldValue;
            try {
                HashEntry<K,V>[] tab = table;
                int index = (tab.length - 1) & hash;//定位HashEntry
                HashEntry<K,V> first = entryAt(tab, index);
                for (HashEntry<K,V> e = first;;) {
                    if (e != null) {
                        K k;
                         // 遍历该 HashEntry,如果不为空则判断传入的 key 和当前遍历的 key 是否相等,相等则覆盖旧的 value。
                        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 {
                        // 不为空则需要新建一个 HashEntry 并加入到 Segment 中
                        if (node != null)
                            node.setNext(first);
                        else
                            node = new HashEntry<K,V>(hash, key, value, first);
                        //若c超出阈值threshold,需要扩容并rehash。
                        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;
        }

get方法

public V get(Object key) {
        Segment<K,V> s; 
        HashEntry<K,V>[] tab;
        int h = hash(key);
        long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;        //先定位Segment,再定位HashEntry
        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;
    }

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

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

JDK1.8

[图片来源]:https://cloud.tencent.com/developer/article/1447127

gwu3e99ry0.png

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

跟1.8的HashMap很像,也把之前的HashEntry改成了Node,但是作用不变,把值和next采用了volatile去修饰,保证了可见性,并且也引入了红黑树,在链表大于一定值的时候会转换(默认是8)。

注意这里的synchronized,只锁定了当前链表或红黑二叉树的首节点,也就是说只有发生了hash不冲突,才会触发synchronized同步机制。在效率上又会有提升。

put操作如下:

  1. 根据 key 计算出 hashcode 。
  2. 判断是否需要进行初始化。
  3. 即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功。
  4. 如果当前位置的 hashcode == MOVED == -1,则需要进行扩容。
  5. 如果都不满足,则利用 synchronized 锁写入数据。
  6. 如果数量大于 TREEIFY_THRESHOLD 则要转换为红黑树。
 final V putVal(K key, V value, boolean onlyIfAbsent) {
        if (key == null || value == null) throw new NullPointerException();//K,V都不能为空,否则的话跑出异常
        int hash = spread(key.hashCode());    //取得key的hash值
        int binCount = 0;    //用来计算在这个节点总共有多少个元素,用来控制扩容或者转移为树
        for (Node<K,V>[] tab = table;;) {    //
            Node<K,V> f; int n, i, fh;
            if (tab == null || (n = tab.length) == 0)    
                tab = initTable();    //第一次put的时候table没有初始化,则初始化table
            else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {    //通过哈希计算出一个表中的位置因为n是数组的长度,所以(n-1)&hash肯定不会出现数组越界
                if (casTabAt(tab, i, null,        //如果这个位置没有元素的话,则通过cas的方式尝试添加,注意这个时候是没有加锁的
                             new Node<K,V>(hash, key, value, null)))        //创建一个Node添加到数组中区,null表示的是下一个节点为空
                    break;                   // no lock when adding to empty bin
            }
            /*
             * 如果检测到某个节点的hash值是MOVED,则表示正在进行数组扩张的数据复制阶段,
             * 则当前线程也会参与去复制,通过允许多线程复制的功能,一次来减少数组的复制所带来的性能损失
             */
            else if ((fh = f.hash) == MOVED)    
                tab = helpTransfer(tab, f);
            else {
                /*
                 * 如果在这个位置有元素的话,就采用synchronized的方式加锁,
                 *     如果是链表的话(hash大于0),就对这个链表的所有元素进行遍历,
                 *         如果找到了key和key的hash值都一样的节点,则把它的值替换到
                 *         如果没找到的话,则添加在链表的最后面
                 *  否则,是树的话,则调用putTreeVal方法添加到树中去
                 *  
                 *  在添加完之后,会对该节点上关联的的数目进行判断,
                 *  如果在8个以上的话,则会调用treeifyBin方法,来尝试转化为树,或者是扩容
                 */
                V oldVal = null;
                synchronized (f) {
                    if (tabAt(tab, i) == f) {        //再次取出要存储的位置的元素,跟前面取出来的比较
                        if (fh >= 0) {                //取出来的元素的hash值大于0,当转换为树之后,hash值为-2
                            binCount = 1;            
                            for (Node<K,V> e = f;; ++binCount) {    //遍历这个链表
                                K ek;
                                if (e.hash == hash &&        //要存的元素的hash,key跟要存储的位置的节点的相同的时候,替换掉该节点的value即可
                                    ((ek = e.key) == key ||
                                     (ek != null && key.equals(ek)))) {
                                    oldVal = e.val;
                                    if (!onlyIfAbsent)        //当使用putIfAbsent的时候,只有在这个key没有设置值得时候才设置
                                        e.val = value;
                                    break;
                                }
                                Node<K,V> pred = e;
                                if ((e = e.next) == null) {    //如果不是同样的hash,同样的key的时候,则判断该节点的下一个节点是否为空,
                                    pred.next = new Node<K,V>(hash, key,        //为空的话把这个要加入的节点设置为当前节点的下一个节点
                                                              value, null);
                                    break;
                                }
                            }
                        }
                        else if (f instanceof TreeBin) {    //表示已经转化成红黑树类型了
                            Node<K,V> p;
                            binCount = 2;
                            if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,    //调用putTreeVal方法,将该元素添加到树中去
                                                           value)) != null) {
                                oldVal = p.val;
                                if (!onlyIfAbsent)
                                    p.val = value;
                            }
                        }
                    }
                }
                if (binCount != 0) {
                    if (binCount >= TREEIFY_THRESHOLD)    //当在同一个节点的数目达到8个的时候,则扩张数组或将给节点的数据转为tree
                        treeifyBin(tab, i);    
                    if (oldVal != null)
                        return oldVal;
                    break;
                }
            }
        }
        addCount(1L, binCount);    //计数
        return null;
    }

拓展

HashTable与HashMap的区别?
  1. 散列方式
    • hashMap采用的是hash&(length-1);
    • hashtable采用的取模运算;
  2. 容器整体结构
    • hashmap中的key和value值都允许为null;1.7版本底层为数组+链表,1.8版本之后则为数组+链表+红黑树;
    • hashtable的key和value值都不允许为null,否则返回NullPointerException;底层为数组+链表。
  3. 扩容机制
    • hashmap默认初始化容量为16,容器容量一定是2的N次方;
    • hashtable默认初始化容量为11,扩容是以原容量的2倍+1扩容;
  4. 线程安全方面
    • hashtable的操作方法都带有synchronized关键字修饰,为线程安全;
    • hashmap为线程不安全。

Collections.synchronizedMap、HashTable与ConcurrentHashMap的使用场景

首先,我认为如果在并发度要求比较高的情况下,数据的一致性不是强一致性的话,首选ConcurrentHashMap。

相反的,对于强一致性问题,还是选择hashTable,因为在同一时间内,也只能让一个线程操作。

至于Collections.synchronizedMap,其实我是纠结的,或者说有点不确定性,因为我觉得它应该也可以保证强一致性,因为它锁住的当前对象,他对并发度的支持相对于hashTable来说,高一点。

如有大佬了解,或者我说的不正确,请联系我!!!!

总结

以上就是对集合不安全问题的总结了,其实要想完整的理解下来,对于初学者而言,难度还是很高的,其实有时候写文章的时候,总在思考怎么把文章写的浅显易懂,怎么有条理性,其实我还是很排斥源码的,因为有时候读者读者读者就会迷失。

这篇文章其实也花了很长的时间去写,如有不正确的地方,欢迎大佬们来指正~~~

Reference

CopyOnWriteArrayList与Collections.synchronizedList的性能对比

吊打面试官》系列-HashMap

创建单链表的头插法与尾插法详解

ConcurrentHashMap实现原理

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
Java高并发线程安全集合是指在多线程环境下能够保证数据一致性和线程安全的数据结构。Java提供了许多高并发线程安全集合,包括ConcurrentHashMap、ConcurrentSkipListMap、ConcurrentSkipListSet、CopyOnWriteArrayList、CopyOnWriteArraySet等。 ConcurrentHashMap是一个线程安全的哈希表,它允许多个线程同时读取并修改其中的元素。它使用分段锁的方式来实现并发访问,不同的线程可以同时访问不同的分段,从而提高了并发性能。 ConcurrentSkipListMap是一个基于跳表的并发有序映射,它可以提供较好的并发性能,且支持按照键的顺序进行遍历。它的实现是通过通过多层链表实现的,每一层链表中的节点按照键的顺序排列。 ConcurrentSkipListSet是一个基于ConcurrentSkipListMap的并发有序集合,它实现了Set接口,并且保证元素的有序性和线程安全性。 CopyOnWriteArrayList是一个线程安全的ArrayList,它通过每次修改时创建一个新的副本来实现线程安全。虽然在插入和删除操作时需要复制整个数组,但读取操作非常高效,适用于读操作远多于写操作的场景。 CopyOnWriteArraySet是一个线程安全的Set,它是基于CopyOnWriteArrayList实现的。它通过复制整个数组来实现线程安全,保证了元素的唯一性和线程安全。 这些高并发线程安全集合多线程环境中保证了数据的一致性和线程安全性,能够提高并发性能和效率,适用于高度并发和需要频繁读写的场景。但需要注意的是,并发集合在某些操作上可能损失一些性能,因此在选择使用时需根据具体需求进行权衡和选择。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值