【Java并发编程 十一】JUC并发包下并发容器类(上)

简介: 【Java并发编程 十一】JUC并发包下并发容器类(上)

JUC包下的容器类分为两部分,一部分是并发集合类,一部分是并发队列类,其中并发集合类可以解决我们集合使用过程中的多线程并发问题,而并发队列类则主要被当做阻塞队列使用,是线程池中的关键参数之一。接下来我们分两部分来详细介绍下这部分内容。

普通集合类

Java集合框架主体内容包括Collection集合和Map类;而Collection集合又可以划分为List(队列)、Set(集合)以及队列(Queue)Map类也有自己的不同实现类。依据用途,我们可以把List、Set和Map理解为集合类,Queue当做队列类。

普通集合类概述

List的实现类主要有: LinkedList、ArrayList、Vector、Stack。

  • LinkedList是双向链表实现的双端队列;它不是线程安全的,只适用于单线程。
  • ArrayList是数组实现的队列,它是一个动态数组;它也不是线程安全的,只适用于单线程。
  • Vector是数组实现的矢量队列,它也一个动态数组;不过和ArrayList不同的是,Vector是线程安全的的,它支持并发。
  • Stack是Vector实现的栈,和Vector一样,它也是线程安全的

综上,Vector和Stack线程安全,LinkedList和ArrayList只适合单线程使用。

Set的实现类主要有: HastSet、LinkedHashSet、TreeSet

  • HashSet 是一个没有重复元素的集合,它通过HashMap实现的;HashSet不是线程安全的,只适用于单线程。
  • LinkedHashSet继承自HashSet,唯一的区别是LinkedHashSet内部使用的是LinkHashMap。这样做的意义或者好处就是LinkedHashSet中的元素顺序是可以保证的,也就是说遍历序和插入序是一致的
  • TreeSet也是一个没有重复元素的集合,不过和HashSet不同的是,TreeSet中的元素是有序的;它是通过TreeMap实现的;TreeSet也不是线程安全的,只适用于单线程。

综上, HastSet、TreeSet、LinkedHashSet都只适合单线程使用。

Map的实现类主要有: HashMap、LinkedHashMap、TreeMap、Hashtable、WeakHashMap,。

  • HashMap是存储键-值对的哈希表;它不是线程安全的,只适用于单线程。
  • LinkedHashMap 是HashMap的一个子类,保存了记录的插入顺序,在用Iterator遍历LinkedHashMap时,先得到的记录肯定是先插入的
  • TreeMap也是哈希表,不过TreeMap中的“键-值对”是有序的,它是通过R-B Tree(红黑树)实现的;TreeMap不是线程安全的,只适用于单线程
  • WeakHashMap是也是哈希表;和HashMap不同的是,HashMap的“键”是强引用类型,而WeakHashMap的“键”是弱引用类型,也就是说当WeakHashMap 中的某个键不再正常使用时,会被从WeakHashMap中被自动移除,WeakHashMap也不是线程安全的,只适用于单线程。
  • Hashtable也是哈希表;和HashMap不同的是,Hashtable是线程安全的,支持并发。

综上, Map里只有Hashtable是线程安全的,支持并发。

线程不安全的问题

如果使用线程不安全的集合极容易出现问题,例如两个线程同时往一个list里添加元素,他们同时判断一个索引上没有值,同时添加,那么实际上只添加了一次,我们举个例子看看:

public class ThreadTest {
    public static void main(String[] args) throws InterruptedException {
        List<String> list=new ArrayList<>();
        for (int i=0;i<500;i++) {
            Thread thread=new Thread(()->{
                try {
                    Thread.sleep(50);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                list.add(Thread.currentThread().getName()+"线程添加的一个元素");
            });
            thread.start();
        }
        Thread.sleep(2000);
        System.out.println("tml说在线程不安全条件下,500个线程并发后list只增加了"+list.size()+"个元素");
    }
}

打印结果为:

tml说在线程不安全条件下,500个线程并发后list只增加了493个元素

如果我们换成线程安全的Vector:

public class ThreadTest {
    public static void main(String[] args) throws InterruptedException {
        Vector<String> vector=new Vector<>();
        for (int i=0;i<500;i++) {
            Thread thread=new Thread(()->{
                try {
                    Thread.sleep(50);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                vector.add(Thread.currentThread().getName()+"线程添加的一个元素");
            });
            thread.start();
        }
        Thread.sleep(2000);
        System.out.println("tml说在线程安全条件下,500个线程并发后vector也增加了"+vector.size()+"个元素");
    }
}

返回结果为:

tml说在线程安全条件下,500个线程并发后vector也增加了500个元素

其实不光写会造成问题,在同一时间多个线程无法对同一个List进行读取和增删,否则就会抛出并发异常,因为在读的时候被别人改了

Exception in thread "Thread-403" java.lang.ArrayIndexOutOfBoundsException: 366
  at java.util.ArrayList.add(ArrayList.java:463)
  at com.company.ThreadTest.lambda$main$0(ThreadTest.java:18)
  at java.lang.Thread.run(Thread.java:748)
tml说在线程不安全条件下,500个线程并发后list只增加了491个元素

如何线程安全

综合以上考虑,线程安全的实现类有vector,stack,hashtable 为了方便,我们将前面介绍集合类统称为java集合包。java集合包大多是非线程安全的,虽然可以通过Collections工具类中的方法获取java集合包对应的同步类,但是这些同步类的并发效率并不是很高。为了更好的支持高并发任务,Java在JUC包中添加了java集合包中单线程类的对应的支持高并发的类。例如,ArrayList对应的高并发类是CopyOnWriteArrayListHashMap对应的高并发类是ConcurrentHashMap,等等。

并发集合类

考虑到普通集合类的问题,我们来看看并发集合类如何解决这样的问题。对应于普通集合类的List、Set以及Map,JUC包中都一一提供了对应的实现方式。在并发编程中使用HashMap可能导致程序死循环。而使用线程安全的HashTable效率又非常低下,基于以上两个原因,便有了ConcurrentHashMap的登场机会

ConcurrentHashMap

ConcurrentHashMap是线程安全且高效的HashMap。一起了解下该容器是如何在保证线程安全的同时又能保证高效的操作。

  • 线程不安全的HashMap,在多线程环境下,使用HashMap进行put操作会引起死循环,导致CPU利用率接近100%,所以在并发情况下不能使用HashMap,因为多线程会导致HashMap的Entry链表形成环形数据结构,一旦形成环形数据结构,Entry的next节点永远不为空,就会产生死循环获取Entry
  • 效率低下的HashTable,HashTable容器使用synchronized来保证线程安全,但在线程竞争激烈的情况下HashTable的效率非常低下。因为当一个线程访问HashTable的同步方法,其他线程也访问HashTable的同步方法时,会进入阻塞或轮询状态。如线程1使用put进行元素添加,线程2不但不能使用put方法添加元素,也不能使用get方法来获取元素,所以竞争越激烈效率越低

HashTable容器在竞争激烈的并发环境下表现出效率低下的原因是所有访问HashTable的线程都必须竞争同一把锁,假如容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效提高并发访问效率,这就是ConcurrentHashMap所使用的锁分段技术。首先将数据分成一段一段地存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问

ConcurrentHashMap结构

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

  • Segment,一个ConcurrentHashMap里包含一个Segment数组。Segment的结构和HashMap类似,是一种数组和链表结构,【JDK1.7】Segment是一种可重入锁(ReentrantLock),在ConcurrentHashMap里扮演锁的角色
  • HashEntry,则用于存储键值对数据。一个Segment里包含一个HashEntry数组,每个HashEntry是一个链表结构的元素,每个Segment守护着一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时,必须首先获得与它对应的Segment锁

整体结构如下,数据结构为:Segment+HashEntry数组+链表

JDK 1.8中ConcurrentHashMap的实现已经摒弃了Segment的概念,而是直接使用Node数组+链表+红黑树(与HashMap的底层实现相同)的数据结构实现,并发控制使用了synchronized和CAS操作。整体就像是优化过且线程安全的HashMap,虽然在JDK 1.8中还能看到Segment的数据结构,但已经简化了其属性,只是为了兼容旧版本

ConcurrentHashMap初始化

ConcurrentHashMap初始化方法是通过initialCapacity、loadFactor和concurrencyLevel等几个参数来初始化segment数组、段偏移量segmentShift、段掩码segmentMask和每个segment里的HashEntry数组来实现的

  • initialCapacity,ConcurrentHashMap的初始容量,初始默认为16
  • concurrencyLevel/ssize,segments数组的大小,默认为16,最大为65536,concurrencyLevel 表示并发度,默认16。并发度可以理解为程序运行时能够同时更新ConccurentHashMap且不产生锁竞争的最大线程数,实际上就是ConcurrentHashMap中的分段锁个数,即segments数组的长度
  • loadFactor, 扩容因子,默认0.75,当一个Segment存储的元素数量大于threshold时,该Segment会进行一次扩容
  • cap,segment里HashEntry数组的长度,为initialCapacity除以ssize的倍数,如果c大于1,就会取大于等于c的2的N次方值,所以cap不是1就是2的N次方
  • threshold,单个segment的容量,值为threshold = (int)cap*loadFactor

那么我们计算的时候可以依据初始值来进行一系列计算。例如initialCapacity为16个元素,负载因子设置为0.75,ssize为16,则c=16/16等于1,则cap为1,也就是每个segment数组长度为1,threshold 容量为(int)1*0.75=0

初始化segments数组

下面为初始化segments数组的源码

if(concurrencyLevel > MAX_SEGMENTS) 
  concurrencyLevel = MAX_SEGMENTS;
int sshift = 0;
int ssize = 1;
while (ssize < concurrencyLevel) {
    ++sshift;
    ssize <<= 1;
}
segmentShift = 32 - sshift;
segmentMask = ssize -1;
this.segments = Segment.newArray(ssize);

由上面代码可知,ssize用位运算来计算(ssize <<= 1),所以segments数组的大小取值为2的N次方,即为大于或等于concurrencyLevel的最低的N次方值来作为segment数组的长度。假如concurrencyLevel等于14、15或16,ssize都会等于16,即容器里锁的个数也是16

当然concurrencyLevel最大只能用16位的二进制来表示,即65535,这意味着segments数组的长度最大为65536,对应的二进制为16位

初始化segmentShift和segmentMask

这两个全局变量需要在定位segment的时的散列算法里使用,由初始化segments数组的代码中可知,

  • sshift等于ssize从1向左移位的次数,在默认情况下concurrencyLevel等于16,则1需要向左移位移动4次,所以sshift等于4
  • 段偏移量segmentShift用于定位参与散列预算的位数,segmentShift = 32 - sshift,所以默认为28.
  • segmentMask是散列运算的掩码,segmentMask = ssize -1,即默认为15,掩码的二进制各个位的值都是1。

因为ssize的最大长度为65536,所以segmentShift最大值为16,segmentMask最大值为65535,对应的二进制为16位,每个位都是1。

相关文章
|
6天前
|
安全 Java Go
Java vs. Go:并发之争
【4月更文挑战第20天】
12 1
|
6天前
|
数据采集 存储 Java
高德地图爬虫实践:Java多线程并发处理策略
高德地图爬虫实践:Java多线程并发处理策略
|
2天前
|
存储 安全 Java
【Java EE】CAS原理和实现以及JUC中常见的类的使用
【Java EE】CAS原理和实现以及JUC中常见的类的使用
|
7天前
|
Java API
[Java 并发基础]多线程编程
[Java 并发基础]多线程编程
|
7天前
|
安全 Java 调度
[Java并发基础] 共享内存
[Java并发基础] 共享内存
|
7天前
|
Java API 调度
[Java并发基础]多进程编程
[Java并发基础]多进程编程
|
7天前
|
设计模式 Java 编译器
深入理解Java中的多线程并发控制
Java作为一种流行的编程语言,其多线程并发控制机制一直是开发者关注的焦点。本文旨在通过探讨Java中的多线程并发控制原理、常用同步工具及设计模式,帮助读者深入理解并有效应用多线程并发控制技术,以提高程序性能和稳定性。
|
7天前
|
监控 安全 Java
一文讲明白Java中线程与进程、并发与并行、同步与异步
一文讲明白Java中线程与进程、并发与并行、同步与异步
7 1
|
12天前
|
监控 安全 Java
【JavaEE多线程】深入解析Java并发工具类与应用实践
【JavaEE多线程】深入解析Java并发工具类与应用实践
28 1
|
12天前
|
安全 Java
深入理解 Java 多线程和并发工具类
【4月更文挑战第19天】本文探讨了Java多线程和并发工具类在实现高性能应用程序中的关键作用。通过继承`Thread`或实现`Runnable`创建线程,利用`Executors`管理线程池,以及使用`Semaphore`、`CountDownLatch`和`CyclicBarrier`进行线程同步。保证线程安全、实现线程协作和性能调优(如设置线程池大小、避免不必要同步)是重要环节。理解并恰当运用这些工具能提升程序效率和可靠性。