并发编程 (三)
1 Fork/Join分解合并框架
1.1 什么是fork/join
Fork/Join框架是JDK1.7提供的一个用于并行执行任务的框架,开发者可以在不去了解如Thread、Runnable等相关知识的情况下,只要遵循fork/join开发模式,就完成写出很好的多线程并发任务。
同时其按照分而治之的思想,可以把一个大任务分割成若干个小任务,最终汇总每个小任务结果后得到大任务结果的框架。
对于Fork/Join框架的理解可以认为其由两部分组成,Fork就是把一个大任务切分为若干个子任务并行执行。Join就是合并这些子任务的执行结果,最后得到这个大任务的结果。
1.2 工作窃取算法
即当前线程的 Task 已经全被执行完毕,则自动取到其他线程的 Task 池中取出 Task 继续执行。ForkJoinPool 中维护着多个线程(一般为 CPU 核数)在不断地执行 Task,每个线程除了执行自己任务列表内的 Task 之外,还会根据自己工作线程的闲置情况去获取其他繁忙的工作线程的 Task,如此一来就能能够减少线程阻塞或是闲置的时间,提高 CPU 利用率。
1.3 Fork/Join的使用
1.3.1 基本概念
要使用Fork/Join的话,首先需要有一个Pool。通过它可以来执行任务。 而每一个任务叫做ForkJoinTask,其内部提供了fork和join的操作机制。通常情况下开发者不需要直接继承ForkJoinTask,而是继承它的子类。分别为:
-
RecursiveAction:返回没有结果的任务。
-
RecursiveTask:返回有结果的任务。
1)新建ForkJoinPool;
2)新建ForkJoinTask(RecursiveAction || RecursiveTask);
3)在任务中的compute方法,会根据自定义条件进行任务拆分,如果条件满足则执行任务,如果条件不满足则继续拆分任务。当所有任务都执行完,进行最终结果的汇总。
4)最终通过get或join获取数据结果。
1.3.2 同步有结果值返回
需求:统计整型数组中所有元素的和。
//生成随机数组
public class GenArray {
//数组长度
public static final int ARRAY_LENGTH=400000;
public static int[] genArray(){
Random random = new Random();
int[] result = new int[ARRAY_LENGTH];
for (int i = 0; i < ARRAY_LENGTH; i++) {
//随机数填充数组
result[i]= random.nextInt(ARRAY_LENGTH*3);
}
return result;
}
}
//普通循环累加
public class SumNormal {
public static void main(String[] args) {
int count = 0;
int[] src = GenArray.genArray();
long start = System.currentTimeMillis();
for (int i = 0; i < src.length; i++) {
count+=src[i];
}
System.out.println("spend time: "+(System.currentTimeMillis()-start));
}
}
//forkJoin累加
public class SumForkJoin{
//自定义任务
private static class SumTask extends RecursiveTask<Integer> {
//阈值
private final static int THRESHOLD=GenArray.ARRAY_LENGTH/10;
private int[] src;
private int fromIndex;
private int endIndex;
public SumTask(int[] src, int fromIndex, int endIndex) {
this.src = src;
this.fromIndex = fromIndex;
this.endIndex = endIndex;
}
@Override
protected Integer compute() {
//判断是否符合任务大小
if (endIndex-fromIndex<THRESHOLD){
//符合条件
int count = 0;
for (int i = fromIndex; i <= endIndex; i++) {
count+=src[i];
}
return count;
}else {
//不符合条件
//继续拆分任务
//基于二分查找对任务进行拆分
int mid = (fromIndex+endIndex)/2;
SumTask left = new SumTask(src,fromIndex,mid);
SumTask right = new SumTask(src,mid+1,endIndex);
invokeAll(left,right);
return left.join()+right.join();
}
}
}
public static void main(String[] args) {
ForkJoinPool pool = new ForkJoinPool();
int[] src = GenArray.genArray();
SumTask sumTask = new SumTask(src,0,src.length-1);
long start = System.currentTimeMillis();
pool.invoke(sumTask);
System.out.println("spend time: "+(System.currentTimeMillis()-start));
}
}
根据执行结果可以看到,如果数据量较小的情况下,使用普通循环的效率更高,因为其内部是以总线的形式进行相加。而forkjoin的话,要利用当前可用的CPU核数结合线程的上下文切换,所以存在一定的性能消耗。
但是如果数据量较大的话,可以看到使用forkjoin的效率会明显高于普通for循环。
1.3.3 异步无结果值返回
需求:遍历目录(包含子目录)寻找txt类型文件。
public class FindFile extends RecursiveAction {
private File path;
public FindFile(File path) {
this.path = path;
}
@Override
protected void compute() {
List<FindFile> takes = new ArrayList<>();
//获取指定路径下的所有文件
File[] files = path.listFiles();
if (files != null){
for (File file : files) {
//是否为文件夹
if (file.isDirectory()){
//递归调用
takes.add(new FindFile(file));
}else {
//不是文件夹。执行检查
if (file.getAbsolutePath().endsWith("txt")){
System.out.println(file.getAbsolutePath());
}
}
}
//调度所有子任务执行
if (!takes.isEmpty()){
for (FindFile task : invokeAll(takes)){
//阻塞当前线程并等待获取结果
task.join();
}
}
}
}
public static void main(String[] args) {
ForkJoinPool pool = new ForkJoinPool();
FindFile task = new FindFile(new File("F://"));
pool.submit(task);
//主线程join,等待子任务执行完毕。
task.join();
System.out.println("task end");
}
}
2 并发工具类
在JDK并发包下提供了几个很有用的并发工具类。CountDownLatch、CyclicBarrier、Semaphore、Exchanger。通过他们可以在不同场景下完成一些特定的功能。
2.1 CountDownLatch闭锁
2.1.1 简介
CountDownLatch一般会把它称之为闭锁,其允许一个或多个线程等待其他线程完成操作。
CountDownLatch内部是通过计数器实现,当执行到某个节点后,就会开始等待其他任务执行。每完成一个任务,计数器就会减一,当计数器等于0时,代表任务已全部完成,则恢复之前的等待线程继续向下运行。
2.1.2 使用场景
根据其工作的特性,使用的场景也是比较多的。假设现在要解析一个Excel文件,其内部会存在多个sheet,则设定每个线程解析一个sheet,等到解析完所有sheet后。再进行后续操作。这就是一个很常见的场景。
2.1.3 代码实现
public class CountDownLatchDemo {
static CountDownLatch countDownLatch = new CountDownLatch(5);
//任务线程
private static class TaskThread implements Runnable{
@Override
public void run() {
countDownLatch.countDown();
System.out.println("task thread is running");
}
}
//等待线程
private static class WaitThread implements Runnable{
@Override
public void run() {
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("wait thread is running");
}
}
public static void main(String[] args) throws InterruptedException {
//等待线程执行
for (int i = 0; i < 2; i++) {
new Thread(new WaitThread()).start();
}
for (int i = 0; i < 5; i++) {
new Thread(new TaskThread()).start();
}
TimeUnit.SECONDS.sleep(3);
}
}
2.2 CycliBarrier同步屏障
2.2.1 简介
CycliBarrier翻译过来叫做可循环的屏障。其可以实现当一组线程执行时,当到达某个屏障(同步点)被阻塞,直到最后一个线程到达屏障后,才会让这一组线程继续向下执行。 其内部也是基于计数器思想实现。
对于CycliBarrier来说,其在基本流程的基础上,也进行了一个扩展。查看源码可知,其构造函数不仅可以传入需要等待的线程数,同时还可以传入一个Runnable。对于这个runnable可以作为一个扩展任务来使用。
2.2.2 代码实现
2.2.2.1 基础实现
public class CyclicBarrierDemo {
static CyclicBarrier barrier = new CyclicBarrier(3);
public static void main(String[] args) throws InterruptedException {
new Thread(()->{
try {
System.out.println(Thread.currentThread().getName()+": do somethings");
barrier.await();
} catch (InterruptedException e) {
e.printStackTrace();
} catch (BrokenBarrierException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+":continue somethings");
}).start();
new Thread(()->{
try {
System.out.println(Thread.currentThread().getName()+": do somethings");
barrier.await();
} catch (InterruptedException e) {
e.printStackTrace();
} catch (BrokenBarrierException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+":continue somethings");
}).start();
//主线程
try {
System.out.println(Thread.currentThread().getName()+": do somethings");
barrier.await();
} catch (BrokenBarrierException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+":continue somethings");
}
}
运行结果
Thread-0: do somethings
main: do somethings
Thread-1: do somethings
main:continue somethings
Thread-1:continue somethings
Thread-0:continue somethings
根据运行结果可知,子线程与主线程间首先会进行相互等待,只有等到其他线程都执行完毕后,才能继续向下执行。因为主线程和子线程是由CPU来进行调度,所以顺序不可控。
此时如果将线程数由3改为4则会永久等待,因为没有第四个线程执行await()方法,即没有第四个线程到达屏障,所以之前到达屏障的三个线程都不会继续向下执行。
2.2.2.2 扩展实现
CyclicBarrier还提供了一个更高级的构造函数,不仅可以设置等待线程数量,同时还能够设置一个优先执行的Runnable,方便处理更为复杂的业务场景。
public class CyclicBarrierDemo2 {
static CyclicBarrier barrier = new CyclicBarrier(3,new ExtendTask());
public static void main(String[] args) throws InterruptedException {
new Thread(()->{
try {
System.out.println(Thread.currentThread().getName()+": do somethings");
barrier.await();
} catch (InterruptedException e) {
e.printStackTrace();
} catch (BrokenBarrierException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+":continue somethings");
}).start();
new Thread(()->{
try {
System.out.println(Thread.currentThread().getName()+": do somethings");
barrier.await();
} catch (InterruptedException e) {
e.printStackTrace();
} catch (BrokenBarrierException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+":continue somethings");
}).start();
//主线程
try {
System.out.println(Thread.currentThread().getName()+": do somethings");
barrier.await();
} catch (BrokenBarrierException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+":continue somethings");
}
static class ExtendTask implements Runnable{
@Override
public void run() {
System.out.println("extend task running");
}
}
}
2.2.3 与CountDownLatch的区别
1)CountDownLatch.await 一般阻塞工作线程,所有的进行预备工作的线程执行countDown,而 CyclicBarrier 通过工作线程调用 await 从而自行阻塞,直到所有工作线程达到指定屏障,再大家一起往下走。
2)在控制多个线程同时运行上,CountDownLatch 可以不限线程数量,而CyclicBarrier 是固定线程数。
2.3 Semaphore信号量
2.3.1 简介
其可以用于做流量控制,通过控制同时访问资源的线程数量,从而保证资源能够被更加合理的使用,如连接资源。假设现在要获取几万个文件资源,那么现在可以开启若干线程进行并发读取。但是读取后还要把这些数据写入到数据库。而数据库连接现在只有100个,此时就需要人为干预,控制只有100个线程同时获取数据库连接资源保存数据。
2.3.2 代码实现
public class SemaphoreDemo {
private static final int THREAD_COUNT=30;
private static ExecutorService executorService = Executors.newFixedThreadPool(THREAD_COUNT);
static Semaphore semaphore = new Semaphore(10);
public static void main(String[] args) {
for (int i = 0; i < THREAD_COUNT; i++) {
executorService.execute(()->{
try {
//获取资源
semaphore.acquire();
System.out.println("do somethings");
//释放资源
semaphore.release();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
}
executorService.shutdown();
}
}
根据上述实现,虽然有三十个线程执行,但是每次同时只能有十个线程能同时获取到资源。 如果将释放资源API注释,则只会有十条打印,因为资源已被耗尽,其他线程无法获取到资源。
2.4 Exchanger交换器
2.4.1 简介
Exchanger是一个线程协作工具类,可以进行线程间的数据交换,但是只局限于两个线程间协作。它提供了一个同步点,在这个同步点,两个线程可以交换彼此的数据。
2.4.2 代码实现
public class ExchangerDemo {
private static final Exchanger<Set<String>> exchange = new Exchanger<Set<String>>();
public static void main(String[] args) {
new Thread(new Runnable() {
@Override
public void run() {
Set<String> setA = new HashSet<String>();//存放数据的容器
try {
setA.add("a1");
setA = exchange.exchange(setA);//交换set
/*处理交换后的数据*/
System.out.println(Thread.currentThread().getName()+" : "+setA.toString());
} catch (InterruptedException e) {
}
}
},"setA").start();
new Thread(new Runnable() {
@Override
public void run() {
Set<String> setB = new HashSet<String>();//存放数据的容器
try {
/*添加数据
* set.add(.....)
* set.add(.....)
* */
setB.add("a2");
setB = exchange.exchange(setB);//交换set
/*处理交换后的数据*/
System.out.println(Thread.currentThread().getName()+" : "+setB.toString());
} catch (InterruptedException e) {
}
}
},"setB").start();
}
}
运行结果
setB : [a1]
setA : [a2]
3 Map解析
3.1 HashMap分析
3.1.1 JDK7的HashMap
HashMap在日常开发中是很常见的,在JDK7中其底层是由数组+链表构成,数组被分成一个个桶(bucket),通过哈希值决定了键值对在这个数组中的位置。哈希值相同的键值对,会以链表形式进行存储。每一个键值对会以一个Entry实例进行封装,内部存在四个属性:key,value,hash值,和用于单向链表的next。
当对HashMap初始化时,其构造函数中需要传入两个参数:initialCapacity、loadFactor
hashMap中还有一个变量:threshold(扩容阈值。计算公式:capacity * load factor)
添加数据过程(put)
-
在第一个元素插入HashMap时做一次数组的初始化,先确定初始的数组大小,并计算数组扩容的阈值。
-
使用key进行Hash值计算,然后通过
(n - 1) & hash
判断当前元素存放的位置(这里的 n 指的是数组的长度),用于确定当前键值对要放入哪个Bucket中。思考:为什么这里用“&”而不用“%”;
1、如果用“%”运算的话,会产生大量的Hash冲突,造成Hash散列不均匀,从而导致桶内链表的长度变长,数据的查询效率变低。
2、“&”操作相当于二进制运算,操作性能要比"%"运算要好
-
找到Bucket后,如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同;如果没有重复,则将此Entry放入链表的头部;如果出现重复,则将此Entry放入链表的尾部,同时建立与前一个节点的连接。
-
在插入新值时,如果当前Buckets数组大小达到了阈值,则触发扩容。扩容后,为原大小的两倍。扩容时会产生一个新的数组替换原来的数组,并将原来数组中的值迁移到新数组中。
JDK7 HashMap总结:
底层数据结构:数组+链表
buckets-> 数组。 数组中的每一个元素位置->bucket桶
HashMap是如何解决Hash冲突的:
何为Hash冲突:不同的key计算出来的bucket位置相同
解决方式:每一个桶内都维护了一个链表
计算键值对在桶中的位置:(n-1)&hash(key)
负载因子:0.75
默认数组容量:16
阈值计算公式:数组容量*0.75 数组不是全部用完后才会扩容,而是当使用率达到75%时就会扩容了
当第一次进行put操作时,才会对数组进行实例化
每次扩容为原大小的两倍
扩容时会产生一个新的数组,而不是在原数组上进行累加
3.1.2 JDK7的HashMap扩容流程
3.1.2.1 API调用过程
1)当调用HashMap的put方法时,其内部会调用addEntry方法添加元素。
2)在addEntry中,如果条件满足则调用resize方法进行扩容。扩展为原大小的两倍。
3)在resize方法中,会调用transfer方法根据新的容量去创建新的Entry数组,命名为newTable。
4)在transfer方法中会轮询原table中的每一个Entry重新计算其在新Table上的位置,并以链表形式连接
5)当全部轮询完毕,则在resize方法中将原table替换为新table。
3.1.2.2 图例分析
1)假设现在有一个hashMap,buckets数组大小为2,内部存在三个元素。假设现在通过key%buckets长度,则3、5、7%2 都为1,则这三个元素都进入1号中,形成一个链表。
2)当发生扩容时,根据源码会对原数组进行二倍扩容,则现在buckets数组长度为4。
3)当在transfer方法中对原数组中Entry进行遍历时,首先遍历到key为3的元素,此时需要通过3%4=3。所以该Entry会放入三号桶中。
4)接着遍历到key为7的元素,此时取模结果仍为3,则该Entry也会放入三号桶中。但是在HashMap中采用的是头插法,后进来的元素会放在队列的头部。
5)接着遍历到key为5的元素,此时取模结果为1,则该Entry放入一号桶中。
3.1.3 JDK7hashMap死循环解析
在JDK8之前,生产环境下的系统经常会出现CPU100%占用,当查看堆栈信息,经常发现程序都卡在了hashMap.get()上,当将系统重启就好了。但是过了一段时间就又会这样,而且在测试环境时又没有问题。后来发现是因为在多线程操作hashMap,当进行rehash时,会造成hashMap出现死循环,原因就在于其内部会形成一个循环链表。 该问题在JDK8之后得以解决,但是仍然不推荐在多线程环境下直接使用HashMap,因为有可能会造成数据丢失,建议使用ConcurrentHashMap。
3.1.3.1 死循环出现原因分析
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
//从oldTable中获取元素,并放入newTable中。
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
3.1.3.2 单线程下的Rehash
假设了我们的hash算法就是简单的用key mod 一下表的大小(也就是数组的长度)。其中的Hash表的size=2, 所以key = 3, 7, 5,在mod 2以后都冲突在table[1]里。效果如下:
此时执行数组扩容,按照扩容规则,buckets数组扩容为原大小的两倍,变为长度为4,接着进行rehash重新计算原数组中元素在新数组中的位置。
第一次操作完后,key:3 放入到buckets[3]的位置,此时e指向原数组中的7,e.next也为7,结构如下所示:
接着进入到第二次循环,此时e为7,当执行Entry<K,V> next = e.next
时,next指向5。接着执行后续逻辑,效果如下所示:
第二次操作完后,key:7放入到buckets[3]的位置,并且处于key:3的前面。继续进行遍历,此时e为5,e.next为null。
根据当前流程可以发现,当在JDK7中的hashmap采用的是头插法,会将扩容之前的元素顺序进行反转。
3.1.3.3 并发下的Rehash
假设现在有两个线程,红色为线程一,蓝色为线程二。
扩容前hash结构
此时两个线程同时执行,因为hashmap不能保证线程安全,所以两个操作的是同一个hashmap空间。当进入到transfer(),在执行完Entry<K,V> next = e.next
时,两个线程状态如下所示:
假设线程一在执行到Entry<K,V> next = e.next;
时被挂起了,那么此时线程一记录的e为3,e.next为7。结构如下
接着线程二执行,将整个rehash过程执行完毕。执行完毕效果如下:
接着线程一开始执行,但是线程一之前的记录为e为key3,e.next为key7。因此继续执行的话,会指向线程二Rehash之后的链表。形成结构如下:
此时可以发现问题,按理说,e应该是在next的前面,但是现在顺序发生问题了。
线程一操作的就是线程二Rehash之后的hashMap
接着线程一继续执行后续代码
当一次循环后,效果如下所示:
接着进行第二次循环。此时e指向7,当执行Entry<K,V> next = e.next
时,此时next指向3。效果如下所示:
接着继续循环执行,效果如下所示:
此时可以发现,当这次循环完之后,3中的next指向7,7中的next指向3.此时死循环已经出现。
3.1.4 JDK8的HashMap
JDK8中对于HashMap的存储结构进行了优化,由数组+链表+红黑树组成。这么做的原因是因为:之前查找元素需要遍历链表,时间复杂度取决于链表的长度。
为了优化这部分的开销,在JDK中,如果链表中元素大于等于8个,则将链表转换为红黑树(前提是桶的大小达到64,否则会先对桶进行扩容);当红黑树中元素小于等于6个,则将红黑树转为链表。从而降低查询与添加的时间复杂度。
3.1.5 JDK8的HashMap源码分析
3.1.5.1 put流程
/**
* Implements Map.put and related methods
*
* @param hash hash for key
* @param key the key
* @param value the value to put
* @param onlyIfAbsent if true, don't change existing value
* @param evict if false, the table is in creation mode.
* @return previous value, or null if none
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//初始化时,map中还没有key-value
if ((tab = table) == null || (n = tab.length) == 0)
//利用resize生成对应的tab[]数组
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
//当前桶无元素
tab[i] = newNode(hash, key, value, null);
else {//桶内有元素
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//桶内第一个元素的key等于待放入的key
e = p;
else if (p instanceof TreeNode)
//如果此时桶内已经树化
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {//桶内还是一个链表,则插入链尾(尾插)
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
//变成红黑树
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
//检查是否应该扩容
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
3.1.6 jdk 7 与 jdk 8 中关于HashMap的对比
- jdk 8时红黑树+链表+数组的形式,当桶内元素大于8时,便会树化
- 1.7 table在创建hashmap时分配空间,而1.8在put的时候分配,如果table为空,则为table分配空间。
- 在发生冲突,插入链中时,jdk 7是头插法,jdk 8是尾插法。
3.1.7 知识点延伸
3.1.7.1 HashMap 的buckets长度为什么永远是 2 的幂次方
为了能让存储更加高效,尽量的避免key冲突,让数据尽量均匀的进行分布,因此采用了hash值计算的方式,hash值的范围为-2147483648 到 2147483647。在这40亿的空间中,总的来说一般很难出现碰撞。但是这么大的空间,不可能一次性全部装入内存中,所以不能直接使用这块空间。因此才会对数组长度进行取模运算,根据余数用来对应数组的下标,来确定当前用于存放数据的位置。计算公式就是(n-1)&hash
。所以buckets的长度才永远为2的幂次方。
取模运算不用hash%length
,而使用(length-1)&hash
,是因为&
采用二进制进行操作,比 %
的运算效率高。
3.1.7.2 HashMap负载因子为什么是0.75
根据之前的讲解,负载因子是和扩容机制有关的。扩容公式为:数组容量*负载因子=扩容阈值。 当buckets数组达到阈值时,则会进行扩容操作。那么为什么在hashMap中不管是JDK7还是JDK8对于扩容因子都定义为0.75呢?
HashMap总的来说就是一个数据结构,那数据结构就是为了节省空间和时间。那负载因子的作用就是为了节省空间和时间的。
假设负载因子的值为1.0。那么结合扩容公式可知,当buckets桶数组全部用完之后才会进行扩容。因为在扩容时,hash冲突是无法避免的。因此当负载因子为1.0时,在进行扩容时,会出现更多的hash冲突,可能导致链表长度或红黑树高度会变得更长或更高,导致查询效率的降低。因此负载因子过大,虽然保证了空间,但牺牲了时间。
假设负载的值为0.5。那么结合扩容公式可知,当buckets数组使用一半时,就会触发扩容。因为数组中的元素少,所以出现hash冲突的几率也会变少,所以链表长度或者是红黑树的高度就会降低,从而提升了查询效率。但是这样的话,空间利用率又降低了。原本只要1M就能存储的数据,现在则需要2M。所以负载因子太小,虽然时间效率提升了,但是空间利用率降低了。
3.1.7.3 为什么JDK8采用红黑树,而不采用平衡二叉树
因为平衡二叉树条件太苛刻了,需要一直进行整棵树的平衡进行左旋或右旋的操作,红黑树相对来讲调整的少点,只要达到黑平衡即可。并且红黑树对于节点的增删和查找效率都是较为中肯的。
3.1.7.4 为什么链表转红黑树的阈值是8
因为红黑树的平均查找长度是log(n),长度为8的时候,平均查找长度为3,如果继续使用链表,平均查找长度为8/2=4,这才有转换为树的必要。链表长度如果是小于等于6,6/2=3,虽然速度也很快的,但是转化为树结构和生成树的时间并不会太短。因此8是一个较为合理的值。
3.2 ConcurrentHashMap解析
3.2.1 简介
ConcurrentHashMap是一个线程安全且高效的HashMap。在并发下,推荐使用其替换HashMap。对于它的使用也非常的简单,除了提供了线程安全的get和put之外,它还提供了一个非常有用的方法putIfAbsent,如果传入的键值对已经存在,则返回存在的value,不进行替换; 如果不存在,则添加键值对,返回null。
public class MapDemo {
public static void main(String[] args) {
ConcurrentHashMap<String,String> map = new ConcurrentHashMap<>();
System.out.println("put不存在的值------");
System.out.println(map.put("AA","AA"));
System.out.println(map.get("AA"));
System.out.println("put已存在的key-------------");
System.out.println(map.put("BB","BB"));
System.out.println(map.get("BB"));
System.out.println("putIfAbsent已存在的key-------------");
System.out.println(map.putIfAbsent("AA","AA"));
System.out.println(map.get("AA"));
System.out.println("putIfAbsent不存在的key-------------");
System.out.println(map.putIfAbsent("CC","CC"));
System.out.println(map.get("CC"));
}
}
3.2.2 JDK7的ConcurrentHashMap
3.2.2.1 基础结构
一个ConcurrentHashMap里包含一个Segment数组,结构与HashMap类似(数组+链表)。一个Segment中包含一个HashEntry数组,每个HashEntry就是链表的元素。
Segment是ConcurrentHashMap实现的很核心的存在,Segment翻译过来就是一段,一般把它称之为分段锁。它继承了ReentrantLock,在ConcurrentHashMap中相当于锁的角色,在多线程下,不同的线程操作不同的segment。只要锁住一个 segment,其他剩余的Segment依然可以操作。这样只要保证每个 Segment 是线程安全的,我们就实现了全局的线程安全。
HashEntry则用于存储键值对。
3.2.2.2 构造方法和初始化
根据其构造函数可知,map的容量默认为16,负载因子为0.75。这两个都与原HashMap相同,但不同的在于,其多个参数concurrencyLevel(并发级别),通过该参数可以用来确定Segment数组的长度并且不允许扩容,默认为16。
并发度设置过小会带来严重的锁竞争问题;如果过大,原本位于一个segment内的访问会扩散到不同的segment中,导致查询命中率降低,引起性能下降。
3.2.2.3 API解析
3.2.2.3.1 get()
1)根据key计算出对应的segment
2)获取segment下的HashEntry数组
3)遍历获取每一个HashEntry进行比对。
注意:整个get过程没有加锁,而是通过volatile保证可以拿到最新值。
3.2.2.3.2 put()
初始化segment,因为ConcurrentHashMap初始化时只会初始化segment[0],对于其他的segment,在插入第一个值的时候再进行初始化。经过计算后,将对应的segment完成初始化。
向下调用ensureSegment方法,其内部可以通过cas保证线程安全,让多线程下只有一个线程可以成功。
在put方法中当初始化完Segment后,会调用一个put的重载方法进行键值对存放。首先会调用tryLock()尝试获取锁,node为null进入到后续流程进行键值对存放;如果没有获取到锁,则调用**scanAndLockForPut()**自旋等待获得锁。
在**scanAndLockForPut()**方法中首先会根据链表进行遍历,如果遍历完毕仍然找不到与key相同的HashEntry,则提前创建一个HashEntry。当tryLock一定次数后仍然无法获得锁,则主动通过lock申请锁。
在获得锁后,segment对链表进行遍历,如果某个 HashEntry 节点具有相同的 key,则更新该 HashEntry 的 value 值,否则新建一个节点将其插入链表头部。
如果节点总数超过阈值,则调用rehash()进行扩容。
JDK7 ConcurrentHashMap总结:
数据结构:segement数组+buckets数组+链表
保证数据安全的思想:分段锁
一个segement相当于对应了一个hashmap
segment数组一旦初始化后,则不允许进行扩容,但并不影响buckets数组的扩容
segment数组的默认大小是16
该数组的长度设置的小,会产生大量的并发冲突,设置过大,会导致数据过度的散列,导致数据的命中率降低
如何保证进行并发put时的数据安全性的:CAS+ReentrantLock
3.2.3 JDK8的ConcurrentHashMap
3.2.3.1 与JDK7的区别
在JDK1.8中对于ConcurrentHashMap也进行了升级,主要优化点如下:
1)JDK7中使用CAS+Reentrant保证并发更新的安全,而在JDK8是通过CAS+synchronized保证。因为synchronized拥有了优化,在低粒度加锁下,synchronized并不比Reentrant差;在大量数据操作下,对于JVM的内存压力,基于API的ReentrantLock会开销更多的内存。
2)JDK7的底层使用segment+数组+链表组成。而在JDK8中抛弃了segment,转而使用数组+链表+红黑树的形式实现,从而让锁的粒度能够更细,进一步减少并发冲突的概率;同时也提高的数据查询效率。
3)在JDK7中的HashEntry在JDK8中变为Node,当转化为红黑树后,变为TreeNode。转换的规则与hashMap相同,当链表长度大于等于8则转换为红黑树,当红黑树的深度小于等于6则转换为链表。
3.2.3.2 核心属性
Node类:用于存储键值对。其与JDK7中的HashEntry属性基本相同。
TreeNode类:树节点类,当链表长度大于等于8,则转换为TreeNode。与hashMap不同的地方在于,它并不是直接转换为红黑树,而是把这些节点放在TreeBin对象中,由TreeBin完成红黑树的包装。
TreeBin类:负责TreeNode节点包装,它代替了TreeNode的根节点,也就是说在实际的数组中存放的是TreeBin对象,而不是TreeNode对象。
sizeCtl属性:用于控制table的初始化和扩容。-1表示正在初始化,-N表示由N-1个线程正在进行扩容,0为默认值表示table还没被初始化,正数表示初始化大小或Map中的元素达到这个数量时,则需要扩容了。
3.2.3.3 核心API
get()
get操作的思路比较简单,和HashMap取值过程类似。
put()
put操作较为复杂,需要考虑并发安全性的问题。
/** Implementation for put and putIfAbsent */
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)
/*如果table为空,初始化table*/
tab = initTable();
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
/*CAS向Node数组中存值*/
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
else if ((fh = f.hash) == MOVED)
/*扩容操作,当前线程协助扩容*/
tab = helpTransfer(tab, f);
else {
V oldVal = null;
/*
*基于synchronized锁住数组中的元素
*/
synchronized (f) {
if (tabAt(tab, i) == f) {
/*是链表中的节点*/
if (fh >= 0) {
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
/*存放数据*/
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
/*如果遍历到了最后一个节点,则把它插入到链表尾部*/
if ((e = e.next) == null) {
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,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
if (binCount != 0) {
/*达到阈值8,链表转换为红黑树*/
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
/*Map元素数量+1,检查是否需要扩容*/
addCount(1L, binCount);
return null;
}
JDK8 ConcurrentHashMap总结:
数据结构:buckets数组+链表+红黑树
如何保证进行并发put时的数据安全性的:CAS+Synchronized
3.2.3.4 与hashTable的区别
Hashtable的任何操作都会把整个表锁住,是阻塞的。好处是总能获取最实时的更新,比如说线程A调用putAll写入大量数据,期间线程B调用get,线程B就会被阻塞,直到线程A完成putAll,因此线程B肯定能获取到线程A写入的完整数据。坏处是所有调用都要排队,竞争越激烈效率越低。 更注重安全。
ConcurrentHashMap 是设计为非阻塞的。在更新时会局部锁住某部分数据,但不会把整个表都锁住。同步读取操作则是完全非阻塞的。好处是在保证合理的同步前提下,效率很高。坏处 是严格来说读取操作不能保证反映最近的更新。例如线程A调用putAll写入大量数据,期间线程B调用get,则只能get到目前为止已经顺利插入的部分数据。更注重性能。
4 ThreadLocal
4.1 概述
ThreadLocal是多线程中对于解决线程安全的一个操作类,它会为每个线程都分配一个独立的线程副本从而解决了变量并发访问冲突的问题。ThreadLocal比直接使用synchronized同步机制解决线程安全问题更简单,更方便,且结果程序拥有更高的并发性。
一个经典的例子,使用JDBC操作数据库时,会将每一个线程的Connection放入各自的ThreadLocal中,从而保证每个线程都在各自的 Connection 上进行数据库的操作,避免A线程关闭了B线程的连接。
4.2 ThreadLocal与Synchonized区别
ThreadLocal和Synchonized都用于解决多线程并发访问。Synchronized用于线程间的数据共享,而ThreadLocal则用于线程间的数据隔离。Synchronized通过锁机制使得变量在同一时刻只能被一个线程访问,而ThreadLocal为每一个线程提供一个变量副本,使得每个线程都只能对自己线程内部数据进行维护,从而实现共享数据的线程隔离。
4.3 ThreadLocal入门应用
public class ThreadLocalTest {
static ThreadLocal<String> localVar = new ThreadLocal<>();
static void print(String str) {
//打印当前线程中本地内存中本地变量的值
System.out.println(str + " :" + localVar.get());
//清除本地内存中的本地变量
//一定要调用,否则会造成内存泄漏
localVar.remove();
}
public static void main(String[] args) {
Thread t1 = new Thread(()->{
localVar.set("localVar1");
print("thread1");
System.out.println("after remove : " + localVar.get());
});
Thread t2 = new Thread(()->{
localVar.set("localVar2");
print("thread2");
System.out.println("after remove : " + localVar.get());
});
t1.start();
t2.start();
}
}
运行结果
thread2 :localVar2
after remove : null
thread1 :localVar1
after remove : null
根据结果可知,每个线程都会维护自己的ThreadLocal值。
4.4 实现原理&源码解析
ThreadLocal本质来说就是一个线程内部存储类,从而让多个线程只操作自己内部的值,从而实现线程数据隔离。官方解释如下
翻译过来即:该类提供了线程局部变量的能力,每个线程都拥有独立的变量副本,通过set和get方法进行操作,该类通常是私有的,通过用于存储和线程相关的信息,如用户id、交易id等。
源码结构如下:
源码结构可知,在ThreadLocal内部维护了一个内部类ThreadLocalMap。而且在ThreadLocalMap中又维护了一个Entry内部类和一个Entry数组。有很多人对于ThreadLocal的介绍都会说:ThreadLocal内部维护了一个map,key是当前线程,value为需要存储的数据。根据源码可知,这句话并不准确,实际上,在ThreadLocal内部的维护了一个ThreadLocalMap,每个线程持有一个ThreadLocalMap对象,在ThreadLocalMap中为每一个线程都维护了一个数组table,而这个数组中会通过下标来确定存储数据的位置。
4.4.1 ThreadLocalMap源码解析
//Entry为ThreadLocalMap静态内部类,对ThreadLocal的若引用
//同时让ThreadLocal和储值形成key-value的关系
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
//ThreadLocalMap构造方法
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
//内部成员数组,INITIAL_CAPACITY值为16的常量
table = new Entry[INITIAL_CAPACITY];
//位运算,结果与取模相同,计算出需要存放的位置
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
table[i] = new Entry(firstKey, firstValue);
size = 1;
setThreshold(INITIAL_CAPACITY);
}
ThreadLocalMap在实例化时,会创建一个16位长度的Entry数组,通过hashCode与长度计算出下标值i,接着创建Entry对象,通过计算出的下标值i,确定该Entry在数组中的位置。
根据之前的讲解,每一个线程都拥有一个独属于自己的ThreadLocal,而在ThreadLocal中又会存在ThreadLocalMap,因此相当于每个线程都拥有一个自己的ThreadLocalMap,那么假设在一个线程中声明了不同类型的ThreadLocal,那么其实最终他们对应的是同一个ThreadLocalMap。
ThreadLocal<A> sThreadLocalA = new ThreadLocal<A>();
ThreadLocal<B> sThreadLocalB = new ThreadLocal<B>();
ThreadLocal<C> sThreadLocalC = new ThreadLocal<C>();
对于上述多个类型的ThreadLocal,他们在同一个线程内,对应的是同一个ThreadLocalMap。那么他们在ThreadLocalMap的Entry数组中,又是如何来确定位置的呢?
private void set(ThreadLocal<?> key, Object value) {
Entry[] tab = table;
int len = tab.length;
//计算数组下标值
int i = key.threadLocalHashCode & (len-1);
//遍历tab,如果该key已存在,则更新
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();
if (k == key) {
e.value = value;
return;
}
if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
}
//如果没有遍历到,则创建Entry对象,并放入数组对应位置
tab[i] = new Entry(key, value);
//数组大小自增
int sz = ++size;
//当数组长度大于等于阈值,则执行扩容,扩容为原大小的两倍
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}
private void resize() {
Entry[] oldTab = table;
int oldLen = oldTab.length;
int newLen = oldLen * 2;
Entry[] newTab = new Entry[newLen];
int count = 0;
for (int j = 0; j < oldLen; ++j) {
Entry e = oldTab[j];
if (e != null) {
ThreadLocal<?> k = e.get();
if (k == null) {
e.value = null; // Help the GC
} else {
int h = k.threadLocalHashCode & (newLen - 1);
while (newTab[h] != null)
h = nextIndex(h, newLen);
newTab[h] = e;
count++;
}
}
}
setThreshold(newLen);
size = count;
table = newTab;
}
对于上述源码分析内容,总结如下:
- 对于同一线程的不同ThreadLocal来讲,这些ThreadLocal实例共享一个table数组,然后每个ThreadLocal实例在数组中的下标值i是不同的。
- 对于某一个ThreadLocal来讲,他的下标值i是确定的,在不同线程之间访问时访问的是不同的table数组的同一位置即都为table[i],只不过这个不同线程之间的table是独立的。
4.4.2 set()源码分析
public void set(T value) {
//获取当前线程对象
Thread t = Thread.currentThread();
//根据当前线程对象,获取ThreadLocal中的ThreadLocalMap
ThreadLocalMap map = getMap(t);
//如果map存在
if (map != null)
//执行map中的set方法,进行数据存储
map.set(this, value);
else
//否则创建ThreadLocalMap,并存值
createMap(t, value);
}
4.4.3 get()源码分析
public T get() {
Thread t = Thread.currentThread();
//根据线程对象,获取对应的ThreadLocalMap
ThreadLocalMap map = getMap(t);
if (map != null) {
//获取ThreadLocalMap中对应的Entry对象
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
//获取Entry中的value
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}
private Entry getEntry(ThreadLocal<?> key) {
//确定数组下标位置
int i = key.threadLocalHashCode & (table.length - 1);
//得到该位置上的Entry
Entry e = table[i];
if (e != null && e.get() == key)
return e;
else
return getEntryAfterMiss(key, i, e);
}
4.5 应用场景
对于ThreadLocal在框架底层实现以及实际开发中都非常常见,利用ThreadLocal的特性可以实现线程数据隔离,从而解决多线程数据冲突问题。当某些数据是以线程为作用域并且不同线程具有不同的数据副本的时候,就可以考虑采用ThreadLocal。
4.5.1 Spring事务中的使用
Spring采用Threadlocal来保证单个线程中的数据库操作使用的是同一个数据库连接,同时,采用这种方式可以使业务层使用事务时不需要感知并管理connection对象,通过传播级别管理多个事务配置之间的切换,挂起和恢复。在Spring内部中存在一个类TransactionSynchronizationManager,该类实现了事务管理与数据访问服务的解耦,同时也保证了多线程环境下connection的线程安全问题。
在Spring中,当我们要获取dao层的Bean时,最原始的方式可以通过getBean()来进行获取
ApplicationContext ac = new ClassPathXmlApplicationContext("applicationContext-jdbc.xml");
UserDaoImpl userDaoImpl = (UserDaoImpl) ac.getBean("userDaoImpl");
System.out.println(userDaoImpl.insertUserInfo("zhangsan", 25));
对于spring的事务实现,只要某个类的方法、类或者接口上有事务配置,spring就会对该类的实例生成代理,所以userDaoImpl是UserDaoImpl实例的代理实例的引用,而不是UserDaoImpl的目标实例的引用;
若目标方法被@Transactional修饰,那么代理方法会先执行增强(判断当前线程是否存在connection、不存在则新建并绑定到当前线程等等),然后通过反射执行目标方法,最后回到代理方法执行增强(事务回滚或事务提交、connection归还到连接池)。这里的绑定connection到当前线程就用到了ThreadLocal。
4.5.2 业务中的应用
4.5.2.1 线上日期错误
开发中经常会使用到SimpleDataFormat进行日期格式化,当调用SimpleDataFormat的parse方法进行日期解析时,会先调用SimpleDataFormat内部的Calendar.clear(),然后调用Calendar.add(),如果一个线程先调用了add()然后另一个线程又调用了clear(),这时候parse方法解析的时间就不对了,最终导致部分用户的日期不对。
解决方案:对于这个问题的解决思路,就是让每个线程都拥有一个自己的SimpleDataFormat,可是直接new的方式性能并不好,此时就可以通过ThreadLocal进行解决,使用线程池加上ThreadLocal包装 SimpleDataFormat ,让每个线程有一个 SimpleDataFormat 的副本,从而解决了线程安全的问题,也提高了性能。
4.5.2.2 跨服务方法传参
在项目开发中,有可能存在一个线程横跨若干服务若干方法调用,经常需要传递一些状态性的信息,如用户认证信息等。如果要想完成这件事,其中一种方式可以通过Context上下文对象进行传参,但是通过上下文传参的话,有可能导致参数传不进去,所以通过ThreadLocal进行改造,当set完数据后,只要保证是在同一个线程中,则其他地方还需要get就可以了。
4.6 ThreadLocal经典问题-内存泄露
4.6.1 内存泄露
4.6.1.1 何为内存泄露
ThreadLocal在使用过程中的一个经典问题即:内存泄露。所谓的内存泄露即:程序在申请内存后,无法释放已申请的内存空间。一次内存泄露危害可以忽略,但内存泄露堆积后果则非常严重,无论多少内存,迟早都会被占光。简单一句话就是:不会再被使用的对象或变量占用的内存不能被回收。
4.6.1.2 Java对象的四种引用类型
要想理解内存泄露的话,则必须先要理解Java对象中的四种引用类型:强引用、软引用、弱引用、虚引用。
强引用:最为普通的引用方式,表示一个对象处于有用且必须的状态,如果一个对象具有强引用,则GC并不会回收它。即便堆中内存不足了,宁可出现OOM,也不会对其进行回收
User user = new User();
软引用:表示一个对象处于有用且非必须状态,如果一个对象处于软引用,在内存空间足够的情况下,GC机制并不会回收它,而在内存空间不足时,则会在OOM异常出现之间对其进行回收。但值得注意的是,因为GC线程优先级较低,软引用并不会立即被回收。
User user = new User();
SoftReference softReference = new SoftReference(user);
弱引用:表示一个对象处于可能有用且非必须的状态。在GC线程扫描内存区域时,一旦发现弱引用,就会回收到弱引用相关联的对象。对于弱引用的回收,无关内存区域是否足够,一旦发现则会被回收。同样的,因为GC线程优先级较低,所以弱引用也并不是会被立刻回收。
User user = new User();
WeakReference weakReference = new WeakReference(user);
虚引用:表示一个对象处于无用的状态。在任何时候都有可能被垃圾回收。虚引用的使用必须和引用队列Reference Queue联合使用
User user = new User();
ReferenceQueue referenceQueue = new ReferenceQueue();
PhantomReference phantomReference = new PhantomReference(user,queue);
4.6.1.3 内存泄露原因分析
根据前面对于ThreadLocal的源码分析可知,每一个Thread维护一个ThreadLocalMap,在ThreadLocalMap中的Entry对象继承了WeakReference。其中key为使用弱引用的ThreadLocal实例,value为线程变量的副本。
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
那么为什么要把key定义为使用弱引用的ThreadLocal呢?假设将key定义为强引用,回收ThreadLocal时,因为ThreadLocalMap还持有ThreadLocal的强引用,如果没有手动删除,ThreadLocal不会被回收,最终导致Entry内存泄漏。
为了避免该问题,则将key定义为弱引用,但是当GC时,则会造成因为key是弱引用,因此会被回收掉,但是value是强引用,仍然会存在,最终造成value的内存泄露。
如要避免ThreadLocal内存泄露的出现,也非常的简单。对于ThreadLocal的使用,务必记得要在最后一步执行remove即可。
5 ThreadPoolExecutor线程池
5.1 为什么使用线程池
对于线程的创建和切换代价都是比较大的,为了能够更好的使用线程,所以就产生了线程池的概念。Java中的线程池是运用场景最多的并发框架,几乎所有需要异步或并发执行任务的程序都可以使用线程池。其带来的好处如下:
-
降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
-
提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
-
提高线程的可管理性。线程是稀缺资源,要合理利用分配,通过线程池可以进行统一分配、调优和监控。
5.2 线程池状态
线程池存在五种状态:RUNNING、 SHUTDOWN,、STOP、TIDYING、TERMINATED。
- RUNNING:处于RUNNING状态的线程池能够接受新任务,以及对新添加的任务进行处理。
- SHUTDOWN:处于SHUTDOWN状态的线程池不可以接受新任务,但是可以对已添加的任务进行处理。
- STOP:处于STOP状态的线程池不接收新任务,不处理已添加的任务,并且会中断正在处理的任务。
- TIDYING:当所有的任务已终止,线程池会变为TIDYING状态。当线程池变为TIDYING状态时,会执行钩子函数terminated()。terminated()在ThreadPoolExecutor类中是空的,若用户想在线程池变为TIDYING时,进行相应的处理;可以通过重载terminated()函数来实现。
- TERMINATED:线程池彻底终止的状态。
5.3 线程池创建的各个参数含义
corePoolSize:
核心线程数(线程池基本大小),在没有任务需要执行的时候的线程池大小。当提交一个任务时,线程池创建一个新线程执行任务,直到线程数等于该参数。 如果当前线程数为该参数,后续提交的任务被保存到阻塞队列中,等待被执行。
maximumPoolSize:
线程池中允许的最大线程数,线程池中的当前线程数目不会超过该值。如果当前阻塞队列满了,且继续提交任务,如果当前的线程数小于maximumPoolSize,则会新建线程来执行任务。
keepAliveTime:
线程池空闲时的存活时间,即当线程池没有任务执行时,继续存活的时间。默认情况下,该参数只在线程数大于corePoolSize时才有用。
workQueue:
其必须是BolckingQueue有界阻塞队列,用于实现线程池的阻塞功能。当线程池中的线程数超过它的corePoolSize时,线程会进入阻塞队列进行阻塞等待。
threadFactory:
用于设置创建线程的工厂。ThreadFactory的作用就是提供创建线程的功能的线程工厂。他是通过newThread()方法提供创建线程的功能,newThread()方法创建的线程都是“非守护线程”而且“线程优先级都是默认优先级”。
handler:
线程池拒绝策略。当阻塞队列满了,且没有空闲的工作线程,如果继续提交任务,则必须采取一种策略处理该任务。
AbortPolicy:默认策略,直接抛出异常。
CallerRunsPolicy:用调用者所在的线程执行任务。
DiscardOldestPolicy:丢去阻塞队列的头部任务,并执行当前任务。
DiscardPolicy:直接丢弃任务。
5.4 线程池工作机制
- 如果当前运行的线程少于corePoolSize,则创建新线程来执行任务(执行这一步前,需要获取全局锁)。
- 如果运行的线程等于或多于corePoolSize,则将任务加入BlockingQueue。
- 如果无法将任务加入BlockingQueue,则创建新线程处理任务。
- 如果创建的新线程使当前运行的线程超出maximumPoolSize,任务将被拒绝。
5.5 自定义线程池
public class ThreadPoolDemo {
public static void main(String[] args) {
//创建阻塞队列
LinkedBlockingQueue<Runnable> queue = new LinkedBlockingQueue<>(100);
//创建工厂
ThreadFactory threadFactory = new ThreadFactory() {
AtomicInteger atomicInteger = new AtomicInteger(1);
@Override
public Thread newThread(Runnable r) {
//创建线程把任务传递进去
Thread thread = new Thread(r);
//设置线程名称
thread.setName("MyThread: "+atomicInteger.getAndIncrement());
return thread;
}
};
ThreadPoolExecutor pool = new ThreadPoolExecutor(10,
10,
1,
TimeUnit.SECONDS,
queue,
threadFactory);
for (int i = 0; i < 100; i++) {
pool.execute(new Runnable() {
@Override
public void run() {
//执行业务
System.out.println(Thread.currentThread().getName()+" 进来了");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+"出去了");
}
});
}
}
}
5.5 五种预定义线程池
我们除了可以使用ThreadPoolExecutor自己根据实际情况创建线程池以外,Executor框架也提供了四种线程池,他们都可以通过工具类Executors来创建。
还有一种线程池ScheduledThreadPoolExecutor,它相当于提供了“延迟”和“周期执行”功能的ThreadPoolExecutor。
5.5.1 FixedThreadPool
创建使用固定线程数的线程池。适用于为了满足资源管理而需要限制当前线程数量的场景。同时也适用于负载较重的服务器。其定义如下:
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}
nThreads:
FixedThreadPool 的 corePoolSize 和 maximumPoolSize 都被设置为创建FixedThreadPool 时指定的参数 nThreads。
keepAliveTime:
此处设置为了0L,代表多于的空闲线程会被立即终止。
LinkedBlockingQueue:
FixedThreadPool 使用有界队列 LinkedBlockingQueue 作为线程池的工作队列(队列的容量为 Integer.MAX_VALUE)。
public class FixedThreadPoolCase {
static class Demo implements Runnable {
@Override
public void run() {
String name = Thread.currentThread().getName();
for (int i = 0; i < 2; i++) {
System.out.println(name + ":" + i);
}
}
}
public static void main(String[] args) throws InterruptedException {
ExecutorService exec = Executors.newFixedThreadPool(3);
for (int i = 0; i < 5; i++) {
exec.execute(new Demo());
Thread.sleep(10);
}
exec.shutdown();
}
}
5.5.2 SingleThreadExecutor
只会使用单个工作线程来执行一个无边界的队列。适用于保证顺序地执行各个人物,并且在任意时间点,不会有多个线程存在活动的应用场景。
public static ExecutorService newSingleThreadExecutor() {
return new FinalizableDelegatedExecutorService
(new ThreadPoolExecutor(1, 1,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>()));
}
corePoolSize 和 maximumPoolSize 被设置为 1。其他参数与 FixedThreadPool相同。SingleThreadExecutor 使用有界队列 LinkedBlockingQueue 作为线程池的工作队列(队列的容量为 Integer.MAX_VALUE)。
public class SingleThreadPoolCase {
static int count = 0;
public static void main(String[] args) throws InterruptedException {
ExecutorService exec = Executors.newSingleThreadExecutor();
for (int i = 0; i < 10; i++) {
exec.execute(new Demo());
Thread.sleep(5);
}
exec.shutdown();
}
static class Demo implements Runnable {
@Override
public void run() {
String name = Thread.currentThread().getName();
for (int i = 0; i < 2; i++) {
count++;
System.out.println(name + ":" + count);
}
}
}
}
5.5.3 CachedThreadPool
其是一个大小无界的线程池,会根据需要创建新线程。适用于执行很多的短期异步任务的小程序或者是负载较轻的服务器。
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>());
}
corePoolSize 被设置为 0,即 corePool 为空;maximumPoolSize 被设置为Integer.MAX_VALUE。这里把 keepAliveTime 设置为 60L,意味着 CachedThreadPool中的空闲线程等待新任务的最长时间为60秒,空闲线程超过60秒后将会被终止。
FixedThreadPool 和 SingleThreadExecutor 使用有界队列 LinkedBlockingQueue作为线程池的工作队列。CachedThreadPool 使用没有容量的 SynchronousQueue作为线程池的工作队列,但 CachedThreadPool 的 maximumPool 是无界的。这意味着,如果主线程提交任务的速度高于 maximumPool 中线程处理任务的速度时,
CachedThreadPool 会不断创建新线程。极端情况下,CachedThreadPool 会因为创建过多线程而耗尽 CPU 和内存资源。
public class Demo9CachedThreadPoolCase {
public static void main(String[] args) throws InterruptedException {
ExecutorService exec = Executors.newCachedThreadPool();
for (int i = 0; i < 10; i++) {
exec.execute(new Demo());
Thread.sleep(1);
}
exec.shutdown();
}
static class Demo implements Runnable {
@Override
public void run() {
String name = Thread.currentThread().getName();
try {
//修改睡眠时间,模拟线程执行需要花费的时间
Thread.sleep(1);
System.out.println(name + "执行完了");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
5.5.4 ScheduledThreadPoolExecutor
ScheduledThreadPoolExecutor,继承ThreadPoolExecutor且实现了ScheduledExecutorService接口,它就相当于提供了“延迟”和“周期执行”功能的ThreadPoolExecutor。它可另行安排在给定的延迟后运行命令,或者定期执行命令。它适用于为了满足资源管理的需求而需要限制后台线程数量的场景同时可以保证多任务的顺序执行。
public ScheduledThreadPoolExecutor(int corePoolSize) {
super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
new DelayedWorkQueue());
}
public ScheduledThreadPoolExecutor(int corePoolSize,
ThreadFactory threadFactory) {
super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
new DelayedWorkQueue(), threadFactory);
}
public ScheduledThreadPoolExecutor(int corePoolSize,
RejectedExecutionHandler handler) {
super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
new DelayedWorkQueue(), handler);
}
public ScheduledThreadPoolExecutor(int corePoolSize,
ThreadFactory threadFactory,
RejectedExecutionHandler handler) {
super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
new DelayedWorkQueue(), threadFactory, handler);
}
在ScheduledThreadPoolExecutor的构造函数中,我们发现它都是利用ThreadLocalExecutor来构造的,唯一变动的地方就在于它所使用的阻塞队列变成了DelayedWorkQueue。
DelayedWorkQueue为ScheduledThreadPoolExecutor中的内部类,类似于延时队列和优先级队列。在执行定时任务的时候,每个任务的执行时间都不同,所以DelayedWorkQueue的工作就是按照执行时间的升序来排列,执行时间距离当前时间越近的任务在队列的前面,这样就可以保证每次出队的任务都是当前队列中执行时间最靠前的。
public class Demo9ScheduledThreadPool {
public static void main(String[] args) throws InterruptedException {
ScheduledExecutorService scheduledThreadPool = Executors.newScheduledThreadPool(2);
System.out.println("程序开始:" + new Date());
// 第二个参数是延迟多久执行
scheduledThreadPool.schedule(new Task(), 0, TimeUnit.SECONDS);
scheduledThreadPool.schedule(new Task(), 1, TimeUnit.SECONDS);
scheduledThreadPool.schedule(new Task(), 5, TimeUnit.SECONDS);
Thread.sleep(5000);
// 关闭线程池
scheduledThreadPool.shutdown();
}
static class Task implements Runnable {
@Override
public void run() {
try {
String name = Thread.currentThread().getName();
System.out.println(name + ", 开始:" + new Date());
Thread.sleep(1000);
System.out.println(name + ", 结束:" + new Date());
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
5.5.5 WorkStealingPool
其是JDK1.8中新增的线程池,利用所有运行的CPU来创建一个工作窃取的线程池,是对ForkJoinPool的扩展,适用于非常耗时的操作。
public class WorkStealingPoolDemo {
public static void main(String[] args) throws IOException {
//获取当前可用CPU核数
System.out.println(Runtime.getRuntime().availableProcessors());
//创建线程池
ExecutorService stealingPool = Executors.newWorkStealingPool();
stealingPool.execute(new MyThread(1000));
/**
* 我现在CPU是8个,开启了9个线程,第一个线程一秒执行完,其他的都是两秒
* 此时会有一个线程进行等待,当第一个执行完毕后,会偷取第九个线程执行
*/
for (int i = 0; i < Runtime.getRuntime().availableProcessors(); i++) {
stealingPool.execute(new MyThread(2000));
}
// 因为work stealing 是deamon线程
// 所以当main方法结束时, 此方法虽然还在后台运行,但是无输出
// 可以通过对主线程阻塞解决
System.in.read();
}
static class MyThread implements Runnable{
int time;
public MyThread(int time) {
this.time = time;
}
@Override
public void run() {
try {
TimeUnit.MILLISECONDS.sleep(time);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+" : "+time);
}
}
}