Java——容器的线程安全性

Java基本容器介绍

Collection接口是集合类的根接口,Java中没有提供这个接口的直接的实现类。Set和List两个类继承于它。Set中不能包含重复的元素,也没有顺序来存放。而List是一个有序的集合,可以包含重复的元素。
而Map又是另一个接口,它和Collection接口没有关系。Map包含了key-value键值对,同一个Map里key是不能重复的,而不同key的value是可以相同的。在这里插入图片描述

Java集合容器框架图:
在这里插入图片描述

1.1. List接口

List在collection中的框架图:
在这里插入图片描述

1.1.1. ArrayList实现类

  1. ArrayList实现了可变大小的数组。它允许所有元素,包括null元素。ArrayList没有同步。
    2)size,isEmpty,get,set方法运行时间为常数。但是add方法开销为分摊的常数,添加n个元素需要O(n)的时间。其他的方法运行时间为线性。Size是返回元素的个数。
  2. 每个ArrayList实例都有一个容量(Capacity),即用于存储元素的数组的大小。这个容量可随着不断添加新元素而自动增加,但是增长算法并没有定义。当需要插入大量元素时,在插入前可以调用ensureCapacity方法来增加ArrayList的容量以提高插入效率。
    4)和LinkedList一样,ArrayList也是非同步的(unsynchronized)。
    5)由数组实现的List。允许对元素进行快速随机访问,但是向List中间插入与移除元素的速度很慢。ListIterator只应该用来由后向前遍历ArrayList,而不是用来插入和移除元素。因为那比LinkedList开销要大很多。

在存放数据的数组长度不够时,会进行扩容,即增加数组长度。在Java 8中是默认扩展为原来的1.5倍。

1.1.2. linkedList实现类

LinkedList是双向链接串列(doubly LinkedList),也允许null元素。

随机访问多时采取arraylist,插入删除多时采取linkedlist

1.2. Set接口

同时set中的元素是无序的,存储顺序和取出顺序不一致,即任意的两个元素e1和e2都有e1.equals(e2)=false,Set最多有一个null元素。
Set集合中不能包含重复的元素,每个元素必须是唯一的。Set几乎都是内部用一个Map来实现, 因为Map里的KeySet就是一个Set,而value是假值,全部使用同一个Object。
Set有三种常见的实现:HashSet、 TreeSet和LinkedHashSet。

1.2.1. HashSet实现类

HashSet不允许重复(HashMap的key不允许重复,如果出现重复就覆盖),允许null值非线程安全。 当向HashSet结合中存入一个元素时,HashSet会调用该对象的hashCode()方法来得到该对象的hashCode值,然后根据 hashCode值来决定该对象在HashSet中存储位置。简单的说,HashSet集合判断两个元素相等的标准是两个对象通过equals方法比较相等,并且两个对象的hashCode()方法返回值相等。

注意,如果要把一个对象放入HashSet中,重写该对象对应类的equals方法,也应该重写其hashCode()方法。其规则是如果两个对象通过equals方法比较返回true时,其 hashCode也应该相同。另外,对象中用作equals比较标准的属性,都应该用来计算 hashCode的值。

1.2.2. LinkedHashSet实现类

LinkedHashSet集合同样是根据元素的hashCode值来决定元素的存储位置,但是它同时使用链表维护元素的次序。这样使得元素看起 来像是以插入顺 序保存的,也就是说,当遍历该集合时候,LinkedHashSet将会以元素的添加顺序访问集合的元素。
LinkedHashSet在迭代访问Set中的全部元素时,性能比HashSet好,但是插入时性能稍微逊色于HashSet。
LinkedHashSet就是基于LinkedHashMap实现的。LinkedHashSet介于HashSet和TreeSet之间。它也是一个hash表,但是同时维护了一个双链表来记录插入的顺序。

1.2.3. TreeSet实现类

TreeSet类型是J2SE中唯一可实现自动排序的类型
TreeSet是SortedSet接口的唯一实现类,TreeSet可以确保集合元素处于排序状态。TreeSet支持两种排序方式,自然排序 和定制排序,其中自然排序为默认的排序方式。向 TreeSet中加入的应该是同一个类的对象。
TreeSet判断两个对象不相等的方式是两个对象通过equals方法返回false,或者通过CompareTo方法比较没有返回0
TreeSet采用红黑树算法实现,不允许重复,不允许null值,默认按照升序排序。

1.3. Map接口

Map:一组成对的“键值对”对象,允许你使用键来查找值。其实Map相当于ArrayList或者更简单的数组的一种扩展、推广。

Map容器框架图如图所示:
在这里插入图片描述

Map抽象出来AbstractMap用来继承,该类实现了Map中的大部分API,其他Map的具体实现就可以通过直接继承AbatractMap类即可。
SortedMap也是一个接口,它继承与Map接口。SortedMap中的内容与Map中的区别在于,它是有序的键值对,里面排序的方法是通过比较器(Comparator)实现的。
基于红黑树(Red-Black tree)实现。该映射根据其键的自然顺序进行排序,或者根据创建映射时提供的 Comparator 进行排序,具体取决于使用的构造方法。

1.3.1. HashTable线程安全

HashTable不接受null键和值,是线程安全的。HashMap是非synchronized,而Hashtable是synchronized,这意味着Hashtable是线程安全的,多个线程可以共享一个Hashtable;
由于Hashtable是线程安全的也是synchronized,所以在单线程环境下它比HashMap要慢。如果你不需要同步,只需要单一线程,那么使用HashMap性能要好过Hashtable。如果使用Java 5以上,请直接使用concurrentHashMap。

1.3.2. HashMap线程不安全

HashMap其实是一个”链表散列”的数据结构,即数组和链表的结合体。HashMap可以接受null键值和值。
在这里插入图片描述

从上图中可以看出,HashMap底层就是一个数组结构,数组中的每一项又是一个链表。当新建一个HashMap的时候,就会初始化一个数组。当我们往HashMap中put元素的时候,先根据key的hashCode重新计算hash值,根据hash值得到这个元素在数组中的位置(即下标),如果数组该位置上已经存放有其他元素了,那么在这个位置上的元素将以链表的形式存放,新加入的放在链头,最先加入的放在链尾。如果数组该位置上没有元素,就直接将该元素放到此数组中的该位置上。

1.3.2.1. HashMap的工作原理

HashMap基于hashing原理,我们通过**put()get()**方法储存和获取对象。

put():
当我们将键值对传递给put()方法时,它调用键对象的hashCode()方法来计算hashcode,然后找到桶位置来储存Entry对象:
①如果桶是一个链表则需要遍历判断里面的 hashcode、key 是否和传入 key 相等,如果相等则进行覆盖,并返回原来的值。
②如果桶是空的,说明当前位置没有数据存入;新增一个 Entry 对象写入当前位置。

get():
当获取对象时,通过键对象的equals()方法找到正确的键值对,然后返回值对象。HashMap使用链表来解决碰撞问题,当发生碰撞了,对象将会储存在链表的下一个节点中。 HashMap在每个链表节点中储存键值对对象。
在这里插入图片描述

1.3.2.2. 当两个对象的hashcode相同会发生什么——Hash冲突

因为hashcode相同,所以它们的bucket位置相同,‘碰撞’会发生。因为HashMap使用链表存储对象,这个Entry(包含有键值对的Map.Entry对象)会存储在链表中。

1.3.2.3. 如果两个键的hashcode相同,你如何获取值对象?

当我们调用get()方法,HashMap会使用键对象的hashcode找到bucket位置,然后获取值对象。如果有两个值对象储存在同一个bucket,将会遍历链表直到找到值对象。

1.3.2.4. 如果HashMap的大小超过了负载因子定义的容量,怎么办?

默认的负载因子大小为0.75,也就是说,当一个map填满了75%的bucket时候,和其它集合类(如ArrayList等)一样,将会创建原来HashMap大小的两倍的bucket数组,来重新调整map的大小,并将原来的对象放入新的bucket数组中。这个过程叫作rehash,因为它调用hash方法找到新的bucket位置。

1.3.2.5. HashMap并发情况下出现的问题

①rehash出现死循环(环形链表)
HashMap 扩容的时候会调用 resize() 方法。

首先必须明确的是当HashMap扩容时,会改变链表中的元素的顺序,将元素从链表头部插入。(头插法避免了尾部遍历,因为不断地向这个桶中加数据就会越来遍历越长,为了高效率,所以采用头插法。)

假设多个线程同时操作这个hashmap,两个线程都进行扩容的操作,就会将A.next = B,B.next = A,因为两个线程都会将原来的链表反向插入,所以出现了“环”。

扩容时大小出现变化
因为里面的size属性是transient关键字修饰(不参与序列化),在各个线程中的size副本不会及时同步,在多个线程操作的时候,size将会被覆盖。

1.3.3. TreeMap线程不安全

TreeMap 是一个有序的key-value集合,它是通过红黑树实现的,也可以实现null键和值。该映射根据其键的自然顺序进行排序,或者根据创建映射时提供的 Comparator 进行排序,具体取决于使用的构造方法。另外,TreeMap是非同步的。 它的iterator 方法返回的迭代器是fail-fast的。

1.3.3.1. HashMap和TreeMap比较

(1)HashMap:适用于在Map中插入、删除和定位元素。
(2)Treemap:适用于按自然顺序或自定义顺序遍历键(key)。
(3)HashMap通常比TreeMap快一点(树和哈希表的数据结构使然),建议多使用HashMap,在需要排序的Map时候才用TreeMap.
(4)都不安全
(5)HashMap的结果是没有排序的,而TreeMap输出的结果是排好序的。

1.3.4. ConcurrentMap线程安全!

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

ConcurrentHashmap主要使用Segment来实现减小锁粒度,把HashMap分割成若干个Segment,在put的时候需要锁住Segmentget时候不加锁,使用volatile来保证可见性,当要统计全局时(比如size),首先会尝试多次计算modcount来确定,这几次尝试中,是否有其他线程进行了修改操作,如果没有,则直接返回size。如果有,则需要依次锁住所有的Segment来计算。ConcurrentHashmap大量的利用了volatile,final,CAS等lock-free技术来减少锁竞争对于性能的影响。

如果我们要统计整个ConcurrentHashMap里元素的大小,就必须统计所有Segment里元素的大小后求和。Segment里的全局变量count是一个volatile变量,那么在多线程场景下,我们是不是直接把所有Segment的count相加就可以得到整个ConcurrentHashMap大小了呢?不是的,虽然相加时可以获取每个Segment的count的最新值,但是拿到之后可能累加前使用的count发生了变化,那么统计结果就不准了。所以最安全的做法,是在统计size的时候把所有Segment的put,remove和clean方法全部锁住,但是这种做法显然非常低效。

因为在累加count操作过程中,之前累加过的count发生变化的几率非常小,所以ConcurrentHashMap的做法是先尝试2次通过不锁住Segment的方式来统计各个Segment大小,如果统计的过程中,容器的count发生了变化,则再采用加锁的方式来统计所有Segment的大小。

那么ConcurrentHashMap是如何判断在统计的时候容器是否发生了变化呢?使用modCount变量,在put , remove和clean方法里操作元素前都会将变量modCount进行加1,那么在统计size前后比较modCount是否发生变化,从而得知容器的大小是否发生变化。

1.3.4.1 ConcurrentHashMap和Hashtable的区别

简而言之,在迭代的过程中,ConcurrentHashMap仅仅锁定map的某个部分,而Hashtable则会锁定整个map。

但ConcurrentHashMap并不能完全替代HashTable。两者的迭代器的一致性不同的,HashTable的迭代器是强一致性的,而ConcurrentHashMap是弱一致的。 ConcurrentHashMap的get,clear,iterator 都是弱一致性的。

1.3.4.2 ConcurrentHashMap的实现

put方法

  1. 通过 key 定位到 Segment,之后在对应的 Segment 中进行具体的 put
  2. 利用 scanAndLockForPut() 自旋获取锁
  3. 之后put
  4. 解锁

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

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

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

1.3.4.3 JDK8后更改

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

2. Java同步容器与并发容器

在Java并发编程中,经常听到同步容器、并发容器之说,那什么是同步容器与并发容器呢?
同步容器可以简单地理解为通过synchronized来实现同步的容器,比如Vector、Hashtable以及SynchronizedList等容器,如果有多个线程调用同步容器的方法,它们将会串行执行
其实同步容器与并发容器都为多线程并发访问提供了合适的线程安全,不过并发容器的可扩展性更高。在Java5之前,程序员们只有同步容器,且在多线程并发访问的时候会导致争用,阻碍了系统的扩展性。Java5介绍了并发容器,**并发容器使用了与同步容器完全不同的加锁策略来提供更高的并发性和伸缩性,例如,在ConcurrentHashMap中采用了一种粒度更细的加锁机制,可以称为分段锁,在这种锁机制下,允许任意数量的读线程并发地访问map,并且执行读操作的线程和写操作的线程也可以并发的访问map,同时允许一定数量的写操作线程并发地修改map,所以它可以在并发环境下实现更高的吞吐量,**另外,并发容器提供了一些在使用同步容器时需要自己实现的复合操作,包括putIfAbsent等,但是由于并发容器不能通过加锁来独占访问,所以我们无法通过加锁来实现其他复合操作了。

2.1. 同步容器

Java常用的容器有ArrayList、LinkedList、HashMap等等,这些容器都是非线程安全的。如果有多个线程并发地访问这些容器时,就会出现问题。
因此在编写程序时,必须要求程序员手动地在任何访问到这些容器的地方进行同步处理,这样导致在使用这些容器的时候非常地不方便。所以,Java提供了同步容器供用户使用。
在Java中,同步容器主要包括2类:
1) Vector、Stack、HashTable
Vector(synchronized同步的ArrayList)和Stack(继承自Vector,先进后出)、HashTable(继承自Dictionary,实现了Map接口)是比较老的容器,Thinking in Java中明确指出,这些容器现在仍然存在于JDK中是为了向以前老版本的程序兼容,在新的程序中不应该在使用。
2) Collections类中提供的静态工厂方法创建的类
Collections类是一个工具提供类。在Collections类中提供了大量的方法,比如对集合或者容器进行排序、查找等操作。更重要的是,在它里面提供了几个静态工厂方法来创建同步容器类。
例如:
Collectinons.synchronizedList()
Collections.synchronizedSet()
Collections.synchronizedMap()
Collections.synchronizedSortedSet()
Collections.synchronizedSortedMap()
LinkedList、ArrayList等都是非同步的
例子:

List list = Collections.synchronizedList(new LinkedList());

从同步容器的具体实现源码可知,同步容器中的方法采用了synchronized进行了同步,那么必然会影响到程序的执行性能。同时复合操作并不是线程安全的。于是Java提供了性能更优并发容器。

2.2. 并发容器

java.util.concurrent提供了多种并发容器,总体上来说有4类:

  • 队列Queue类型的BlockingQueue和ConcurrentLinkedQueue
  • Map类型的ConcurrentMap,对应的非并发容器是HashMap
  • Set类型的ConcurrentSkipListSet和CopyOnWriteArraySet
  • List类型的CopyOnWriteArrayList

3. 要注意的一些重要术语

  1. sychronized意味着在一次仅有一个线程能够更改Hashtable。就是说任何线程要更新Hashtable时要首先获得同步锁,其它线程要等到同步锁被释放之后才能再次获得同步锁更新Hashtable。
  2. “快速失败”也就是fail-fast,它是Java集合的一种错误检测机制。当多个线程对集合进行结构上的改变的操作时,有可能会产生fail-fast机制。记住是有可能,而不是一定。例如:假设存在两个线程(线程1、线程2),线程1通过Iterator在遍历集合A中的元素,在某个时候线程2修改了集合A的结构(是结构上面的修改,而不是简单的修改集合元素的内容),那么这个时候程序就会抛出 ConcurrentModificationException 异常,从而产生fail-fast机制
    Fail-fast和iterator迭代器相关。如果某个集合对象创建了Iterator或者ListIterator,然后其它的线程试图“结构上”更改集合对象,将会抛出ConcurrentModificationException异常。但其它线程可以通过set()方法更改集合对象是允许的,因为这并没有从“结构上”更改集合。但是假如已经从结构上进行了更改,再调用set()方法,将会抛出IllegalArgumentException异常。
  3. 结构上的更改指的是删除或者插入一个元素,这样会影响到map的结构。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值