java并发编程的艺术-学习6

第6章 Java并发容器和框架

6.1 ConcurrentHashMap的实现原理与使用

ConcurrentHashMap是线程安全且高效的HashMap。

6.1.1 为什么要使用ConcurrentHashMap

在并发编程中使用HashMap可能导致程序死循环。(不懂)
1、线程不安全的HashMap
在多线程环境下,使用HashMap进行put操作会引起死循环,导致CPU利用率接近100%。
并发执行put操作,导致hashMap的Entry链表形成环形数据结构,一旦形成环形数据结构,Entry的next节点永远不为空,导致死循环。
2、效率低下的hashTable
HashTable容器使用synchronized来保证线程线程安全,线程访问hashTable的同步方法时,会进入阻塞或轮询状态。如线程1使用put进行元素添加,线程2不但不能使用put添加元素,也不能使用get获取元素,竞争越激烈效率越低。
3、ConcurrentHashMap的锁分段技术可有效提升并发访问率
HashTable效率低的原因是所有访问HashTable的线程都必须竞争同一把锁。
ConcurrentHashMap锁分段技术:将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段的数据时,其他段的数据也能被其他线程访问。

6.1.2 ConcurrentHashMap结构

是由Segment数组、HashEntry数组结构组成,Segment是一种可重入锁,HashEntry存储键值对数据。Segment结构和HashMap类似,是一种数组和链表结构。一个Segment包含一个HashEntry数组,每个HashEntry是一个链表结构的元素。当修改HashEntry数组的数据时,必须先获得Segment锁。

6.1.3 ConcurrentHashMap的初始化

ConcurrentHashMap初始化方法是通过initialCapacity、loadFactor、concurrencyLevel等参数来初始化segment数组、段偏移量segmentShift、段掩码segmentMask和每个segment里的HashEntry数组。
1、初始化segments数组
segments数组的长度是通过concurrencyLevel计算的,为了能通过按位与的散列算法来定位segments数组的索引,必须保证segments数组的长度是2的n次方,所以必须计算出一个大于或等于concurrencyLevel的最小2的n次方来作为segments的数组长度。
concurrencyLevel的最大值是65535,意味着segments数组的长度最大值为65536,对应的二进制是16位(不懂)

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

2、初始化segmentShift和segmentMask
这两个全局变量需要在定位segment的散列算法里使用,sshift等于ssize从1向左移位的次数,在默认情况下concurrencyLevel=16,1需要向左移位4次,即sshift=4。segmentShift用于等位参与散列运算的位数,segment=32-sshift=28,这里之所以用32是因为ConcurrentHashMap里的hash()方法输出的最大数是32位。
segmentMask是散列运算的掩码,等于ssize-1,即15,掩码的二进制各个位的值都是1。因为ssize的最大长度是65536,所以segmentShift最大值是16,segmentMask最大值是65535,对应的二进制是16位,每个位都是1。
3、初始化每个segment
输入参数initialCapacity是ConcurrencyHashMap的初始化容量,loadfactor是每个segment的负载因子,在构造方法里需要通过这两个参数来初始化数组中的每个segment。

if (initialCapacity > MAXIMUM_CAPACITY)  {
	initialCapacity = MAXIMUM_CAPACITY;
}
int c = initialCapacity / ssize;
if (c * ssize < initialCapacity) {
	++c;
}
int cap = 1;
while (cap < c) {
	cap <<= 1;
}
for (int i = 0; i < this.segments.length; ++i) {
	this.segment[i] = new Segment<K,V>(cap, loadFactor);
}

代码中cap就是segment里HashEntry数组的长度,等于initialCapacity除以ssize的倍数c,如果c大于1,就会取c的2的N次方值,所以cap不是1就是2的N次方。
segment的容量threshold=(int)cap*loadFactor,默认情况下initialCapacity=16,loadFactor=0.75,通过运算cap=1,threshold=0。(因为默认时ssize=16,c=1)

6.1.4 定位Segment

既然ConcurrencyHashMap使用分段锁Segment来保护不同段的数据,那么在插入和获取元素的时候,必须先通过散列算法定位到Segment。可以看到ConcurrencyHashMap首先使用变种算法对元素的hashCode进行一次再散列。

private static int hash(int h) {
	h += (h << 15) ^ 0xffffcd7d;
	h ^= (h >>> 10);
	h += (h << 3);
	h ^= (h >>>6);
	h += (h << 2) + (h << 14);
	return h ^ (h >>> 16);
}

之所以进行再散列,是为了减少散列冲突,使元素能均匀分布在不同的segment上,从而提高容器的存取效率。

6.1.5 ConcurrentHashMap的操作

1.get操作

整个get过程不需要加锁,除非读到的值是空才会加锁重读。
因为get方法里使用的共享变量都是volatile类型,如用于统计当前segment大小的count字段和用于存储值的HashEntry的value。(volatile替换锁经典场景)
volatile能保证可见性。之所以不会读到过期的值,是因为根据JVM的happens-before原子,写操作先于读操作。
定位Segment使用的是元素的hashcode通过再散列后得到的值的高位,而定位HashEntry直接使用再散列后的值。目的是避免两次散列后的值一样,虽然元素在Segment里散列开了,但是在HashEntry里没有散列开。

2.put操作

首先定位到Segment,然后插入。插入分两步:第一步判断是否需要对Segment里的HashEntry数组进行扩容,第二步定位添加元素的位置,然后将其放置HashEntry数组里。
(1)是否需要扩容
插入元素前会先判断Segment里的HashEntry数组是否超过容量(threshold),如果超过阀值,则对数组扩容。
Segment的扩容判断比HashMap更恰当。因为HashMap是在插入元素后判断扩容,有可能扩容后没有新元素插入,导致扩容无效。
(2)如果扩容
在扩容的时候,首先会创建一个容量是原来两倍的数组,然后将原数组里的元素进行再散列后插入到新的数组里。为了高效,ConcurrentHashMap不会对整个容器进行扩容,而只对某个Segment进行扩容。

3.size操作

尝试2次通过不锁住Segment的(put、remove、clean方法)方式来统计各个Segment的大小,如果统计过程中,容器的count发生变化,则再采用加锁的方式统计。
判断容器发生变化:使用modCount变量,在put、remove、clean方法里操作元素前会将变量modCount进行加1,那么在统计size的前后,比较modCount是否变化就知道。

6.2 ConcurrentLinkedQueue

ConcurrentLinkedQueue是一个基于链接节点的无界线程安全队列。
先进先出、CAS算法

6.2.1 ConcurrentLinkedQueue的结构

由head节点和tail节点组成,每个节点(node)由节点元素(item)和指向下一个节点(next)的引用组成,节点与节点之间通过next关联,组成链表结构的队列。
默认情况:head节点存储的元素为空,tail节点等于head节点。

6.2.2 入队列

  1. 入队列的过程
    入队列就是将入队节点添加到队列的尾部。
    如果tail节点的next不为空,则将入队节点设置成tail节点,如果tail节点的next为空,则将入队节点设置成tail节点的next。所以tail节点不总是尾结点。
  2. 定位尾结点
    获取tail节点的next是否为空,则表示尾结点;不为空,则表示next是尾结点;且tail节点为空,tail节点的next为空,则表示尾结点就是head。
  3. 设置入队节点为尾结点
    CAS设置
  4. HOPS的设计意图
入队方法永远返回true,所以不要通过返回值判断入队是否成功。

6.2.3 出队列

不是每次出队都更新head节点。
head节点里有元素,直接弹出,不更新head;
head节点没有元素,出队才会更新head;

6.3 Java中的阻塞队列

6.3.1 什么是阻塞队列

阻塞队列(BlockingQueue)是一个支持两个附加操作的队列。这两个附加操作支持阻塞的插入和移除。
1)阻塞插入:当队列满时,队列会阻塞插入元素的线程,直到队列不满。
2)阻塞移除:队列为空时,获取队列元素的线程会等队列变为非空。
阻塞队列不可用时,插入和移除提供了4种处理方式:

方法/处理方式抛出异常返回特殊值一直阻塞超时退出
插入方法add(e)offer(e)put(e)offer(e,time,unit)
移除方法remove()poll()take()poll(time,unit)
检查方法element()peek()

6.3.2 Java里的阻塞队列

ArrayBlockingQueue: 一个由数组结构组成的有界阻塞队列
LinkedBlockingQueue:一个由链表结构组成的有界阻塞队列
PriorityBlockingQueue:一个支持优先级排序的无界阻塞队列
DelayQueue:一个使用优先级队列实现的无界阻塞队列
SynchronousQueue:一个不存储元素的阻塞队列
LinkedTransferQueue:一个由链表结构组成的无界阻塞队列
LinkedBlockingDeque:一个由链表结构组成的双向阻塞队列

1.ArrayBlockingQueue
FIFO先进先出原则对元素进行排序。
默认:不保证线程公平的访问队列。
2. LinkedBlockingQueue
FIFO
队列的默认和最大长度为Integer.MAX_VALUE=2的32次方,约20多亿。
3. PriorityBlockingQueue
默认:自然顺序升序排列
不能保证同优先级的顺序
4. DelayQueue
支持延时获取元素的无界阻塞队列。队列使用PriorityQueue来实现
队列中的元素必须实现Delayed接口。在创建元素时可以指定多久才能从队列中获取当前元素。只有在延迟期满时才能从队列中提取元素。
运用场景:
缓存系统的设计:DelayQueue保存缓存元素的有效期,使用一个线程循环查询DelayQueue,一旦能从DelayQueue中获取元素,表示缓存有效期到了。
定时任务调度:保存执行的任务和时间,一旦从DelayQueue中获取到任务就开始执行,比如TimerQueue。
(1)如何实现Delayed接口
参考ScheduledThreadPoolExecutor里的ScheduledFutureTask类的实现。
第一步:在对象创建的时候,初始化基本数据。使用time记录当前对象延迟到什么时候可以使用,使用sequenceNumber来标识元素在队列中的先后顺序。

private static final AtomicLong sequencer = new AtomicLong(0);
ScheduledFutureTask(Runnable r, V result, long ns, long period) {
	ScheduledFutureTask(Runnable r, V result, long ns, long period) {
		super(r,result);
		this.time = ns;
		this.period = period;
		this.sequenceNumber = sequencer.getAndIncrement();
	}
}

第二步:实现getDelay方法,该方法返回当前元素还需要延时多长时间,单位是纳秒。

public long getDelay(TimeUnit unit) {
	return unit.convert(time - now(),TimeUnit.NANOSECONDS);
}

第三步:实现compareTo方法来指定元素的顺序。
例如:让延时时间最长的放在队列的末尾。

public int compareTo(Delayed other) {
	if (other == this) {
		return 0;
	}
	if (other instanceof ScheduledFutureTask) {
		ScheduledFutureTask<?> x = (ScheduledFutureTask<?>)other;
		long diff = time - x.time;
		if (diff < 0) {
			return -1;
		} else if (diff > 0) {
			return 1;
		} else if (sequenceNumber < x.sequenceNumber) {
			return -1;
		} else {
			return 1;
		}
	}
}

(2)如何实现延时阻塞队列

  1. SynchronousQueue
    每一个put操作必须等待take操作,否则不能继续添加元素
    吞吐量高于LinkedBlockingQueue和ArrayBlockingQueue
  2. LinkedTransferQueue
    多了方法transfer()和tryTransfer()。
  3. LinkedBlockingDeque
    运用:工作窃取模式

6.3.3 阻塞队列的实现原理

使用通知模式实现。当生产者往满的队列里添加元素时,会阻塞住生产者,当消费者消费了一个队列中的元素后,会通知生产者当前队列可用。
如ArrayBlockingQueue使用Condition来实现。
不懂。。。。

6.4 Fork/Join框架

6.4.1 什么是Fork/Join框架

Fork/Join框架是Java7提供的用于并行执行任务的框架,把大任务分割成若干个小任务,最终汇总每个小任务结果后得到大任务结果的框架。

6.4.2 工作窃取算法

工作窃取(work-stealing)算法是指某个线程从其他队列里窃取任务来执行。
使用双端队列,被窃取任务线程从队列的头部拿任务执行,窃取任务的线程从队列的尾部拿任务执行。
优点:充分利用线程进行并行计算
缺点:在某些情况下还是存在竞争。比如双端队列里只有一个任务。并且该算法会消耗更多的系统资源,比如创建多个线程和多个双端队列。

6.4.3 Fork/Join框架的设计

步骤1:分割任务;
步骤2:执行任务并合并结果。
ForkJoinTask
RecursiveAction 用于没有返回结果的任务
RecursiveTask 用于有返回结果的任务。
ForkJoinPool

6.4.4 使用Fork/Join框架

6.4.5 Fork/Join框架的异常处理

ForkJoinTask在执行任务的时候可能会抛出异常,但是没办法在主线程里直接捕获异常,所以ForkJoinTask提供了isCompletedAbnormally()方法来检查任务是否已经抛出异常或已经被取消,并可以通过ForkJoinTask的getException方法获取异常。

6.4.6 Fork/Join实现原理

ForkJoinPool由ForkJoinTask数组和ForkJoinWorkerThread数组组成,ForkJoinTask数组负责将存放程序提交给ForkJoinPool的任务,而ForkJoinWorkerThread数组负责执行这些任务。
(1)ForkJoinTask的fork方法实现原理
当调用fork方法时,程序会调用ForkJoinWorkerThread的pushTask方法异步地执行这个任务,然后立即返回结果。
pushTask方法把当前任务存放在ForkJoinTask数组队列里。然后再调用ForkJoinPool的signalWork()方法唤醒或创建一个工作现场来执行任务。
(2)ForkJoinTask的join方法实现原理
join方法的主要作用是阻塞当前线程并等待获取结果。
首先,调用doJoin()方法,通过该方法得到当前任务的状态来判断返回什么结果。
如果任务状态是已完成(NORMAL),则直接返回任务结构。
如果任务状态是被取消(CANCELLED),则直接抛出CancellationException
如果任务状态是抛出异常(EXCEPTIONAL),则直接抛出对于的异常。
任务状态有4种:NORMAL、CANCELLED、SIGNAL、EXCEPTIONAL

6.5 本章小结

本章介绍java中提供的各种并发容器和框架,分析实现原理

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值